Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions src/main/kotlin/team/cklob/mudda/global/config/JacksonConfig.kt
Original file line number Diff line number Diff line change
@@ -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()
}
Comment thread
cfcromn marked this conversation as resolved.
Outdated
25 changes: 25 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,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<String, Any>().apply {
connectionFactory = factory
keySerializer = StringRedisSerializer()
valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper.copy().activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfSubType("team.cklob.mudda").build(),
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY,
))
Comment thread
cfcromn marked this conversation as resolved.
}
}
24 changes: 24 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,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()
Comment thread
cfcromn marked this conversation as resolved.
Outdated
}
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,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)
Comment thread
cfcromn marked this conversation as resolved.
Outdated

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")?.removePrefix("Bearer ")?.takeIf(jwtTokenProvider::validate)?.let { token ->
val authentication = UsernamePasswordAuthenticationToken(jwtTokenProvider.getMemberId(token), null, emptyList())
authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authentication
}
Comment thread
cfcromn marked this conversation as resolved.
Outdated
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,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
Comment thread
cfcromn marked this conversation as resolved.
Outdated
}
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>(), JacksonConfig().objectMapper()).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))
}
}