Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/RedisConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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<String, Any>().apply {
connectionFactory = factory
keySerializer = StringRedisSerializer()
valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper.copy().activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfSubType("team.cklob.mudda")
.allowIfSubType("java.util.")
.allowIfSubType("java.time.")
.build(),
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY,
))
Comment thread
cfcromn marked this conversation as resolved.
}
}
31 changes: 31 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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
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
import team.cklob.mudda.global.exception.ErrorCode
import team.cklob.mudda.global.response.ApiResponse

@Configuration
@EnableConfigurationProperties(JwtProperties::class)
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.status = HttpStatus.UNAUTHORIZED.value()
response.contentType = "application/json"
response.writer.write(objectMapper.writeValueAsString(ApiResponse.failure(ErrorCode.UNAUTHORIZED)))
} }
.addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java).build()
}
12 changes: 12 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -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")))
}
11 changes: 11 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -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<HandlerMethodArgumentResolver>) { resolvers += loginUserArgumentResolver }
}
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt
Original file line number Diff line number Diff line change
@@ -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."),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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
import org.slf4j.LoggerFactory

@RestControllerAdvice
class GlobalExceptionHandler {
private val logger = LoggerFactory.getLogger(javaClass)
@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): ResponseEntity<ApiResponse<Nothing>> {
logger.error("Unexpected exception type: {}", e.javaClass.name)
return response(ErrorCode.INTERNAL_SERVER_ERROR)
}

private fun response(errorCode: ErrorCode): ResponseEntity<ApiResponse<Nothing>> =
ResponseEntity.status(errorCode.status).contentType(MediaType.APPLICATION_JSON).body(ApiResponse.failure(errorCode))
}
12 changes: 12 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/response/ApiResponse.kt
Original file line number Diff line number Diff line change
@@ -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<T>(val success: Boolean, val data: T? = null, val error: ErrorResponse? = null) {
companion object {
fun <T> success(data: T? = null) = ApiResponse(success = true, data = data)
fun failure(errorCode: ErrorCode) = ApiResponse<Nothing>(success = false, error = ErrorResponse(errorCode))
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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")?.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
}
filterChain.doFilter(request, response)
}
}
10 changes: 10 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/security/JwtProperties.kt
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/security/LoginUser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package team.cklob.mudda.global.security

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginUser
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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
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?): Any? {
val principal = SecurityContextHolder.getContext().authentication?.principal as? Long
if (principal == null && !parameter.isOptional) throw AuthException()
return principal
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
1 change: 1 addition & 0 deletions src/test/kotlin/team/cklob/mudda/MuddaApplicationTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class PostgisContainer(imageName: DockerImageName) : PostgreSQLContainer<Postgis
"spring.cloud.aws.region.static=ap-northeast-2",
"spring.cloud.aws.credentials.access-key=test",
"spring.cloud.aws.credentials.secret-key=test",
"jwt.secret=local-test-secret-must-be-at-least-32-bytes",
],
)
@Testcontainers
Expand Down
18 changes: 18 additions & 0 deletions src/test/kotlin/team/cklob/mudda/global/config/RedisConfigTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package team.cklob.mudda.global.config

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import io.mockk.mockk
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.serializer.RedisSerializer

class RedisConfigTest {
@Test fun `serializes Kotlin data classes without field loss`() {
val serializer = RedisConfig().redisTemplate(mockk<RedisConnectionFactory>(), com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()).valueSerializer as RedisSerializer<Any>
val value = CachedValue(1, "capsule")

assertEquals(value, serializer.deserialize(serializer.serialize(value)))
}

private data class CachedValue(val id: Long, val name: String)
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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?)
}
Original file line number Diff line number Diff line change
@@ -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))
}
}