diff --git a/AGENTS.md b/AGENTS.md index d6d3c8c..bb6af20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Kotlin, Spring Boot, Spring Data JPA, Spring Security, JWT, PostgreSQL, Redis, G `user`, `market`, `challenge`, `trading`, `portfolio`, `recommendation` (reaction logging), `learning` -Packages are organized by **domain, not by layer**. Before adding new code, decide which domain it belongs to and place it in that package. +Follow [ARCHITECTURE.md](ARCHITECTURE.md) for the required `domain` and `global` package layout. Before adding code, decide whether it belongs to a domain layer or a global concern and place it there. ## Boundary with the FastAPI AI Service (important) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c0db7f4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Architecture + +## Package Layout + +```text +team.cklob.arena +├── domain +│ └── {domainName} +│ ├── presentation +│ │ ├── request +│ │ ├── response +│ │ └── controller +│ ├── application +│ │ └── impl +│ ├── domain +│ │ ├── entity +│ │ ├── type +│ │ └── repository +│ └── infrastructure +└── global + ├── common + ├── config + ├── security + ├── exception + ├── response + ├── annotation + ├── util + └── property +``` + +`{domainName}` is one of `user`, `market`, `challenge`, `trading`, `portfolio`, `recommendation`, or `learning`. + +Do not create an empty package. Add a package only when it contains production code. + +## Domain Layers + +- `presentation`: HTTP controllers and request/response DTOs. Controllers validate input and delegate only to application services. +- `application`: use cases, orchestration, and transaction boundaries. Place concrete service implementations in `application.impl`. +- `domain`: entities, domain types, and repository interfaces. Keep domain business rules here when they do not require orchestration or external I/O. +- `infrastructure`: external API clients, persistence adapters, and other technology-specific implementations. + +Use the package form `team.cklob.arena.domain.{domainName}.{layer}`. A domain may depend on `global`, but one domain must not reach into another domain's `presentation` or `application` package. + +## Global Packages + +- `common`: cross-cutting types that do not belong to another global concern. +- `config`: Spring configuration and framework integration setup. +- `security`: JWT, authentication, authorization, and Spring Security components. +- `exception`: shared exception contracts, error-code contracts, and global exception handlers. +- `response`: shared API response envelopes and response advice. +- `annotation`: reusable annotations and their supporting code. +- `util`: stateless helpers with no domain ownership. +- `property`: `@ConfigurationProperties` classes. + +`global` must not contain domain business logic, domain entities, or domain repositories. diff --git a/CLAUDE.md b/CLAUDE.md index 21135c3..1349996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,7 @@ Main backend for an AI mock investment battle platform. Users compete against AI `user` · `market` · `challenge` · `trading` · `portfolio` · `recommendation` (reaction logging) · `learning` -- Follow **domain-based package structure**. Avoid layer-first structure (splitting into controller/service/repository at the top level). -- Package convention: `team.cklob.arena.{domain}.{layer}` +Follow [ARCHITECTURE.md](ARCHITECTURE.md) for the required `domain` and `global` package layout. ## Boundary with the FastAPI AI Service diff --git a/src/main/kotlin/team/cklob/arena/ArenaApplication.kt b/src/main/kotlin/team/cklob/arena/ArenaApplication.kt index eb81925..94b7912 100644 --- a/src/main/kotlin/team/cklob/arena/ArenaApplication.kt +++ b/src/main/kotlin/team/cklob/arena/ArenaApplication.kt @@ -1,9 +1,11 @@ package team.cklob.arena import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class ArenaApplication fun main(args: Array) { diff --git a/src/main/kotlin/team/cklob/arena/challenge/Challenge.kt b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/challenge/Challenge.kt rename to src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt index 8b7c9cf..d088257 100644 --- a/src/main/kotlin/team/cklob/arena/challenge/Challenge.kt +++ b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.challenge +package team.cklob.arena.domain.challenge import jakarta.persistence.Column import jakarta.persistence.Entity @@ -15,8 +15,8 @@ import jakarta.persistence.OneToOne import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.market.MarketType -import team.cklob.arena.user.User +import team.cklob.arena.domain.market.MarketType +import team.cklob.arena.domain.user.User import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt new file mode 100644 index 0000000..93a6949 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/challenge/ChallengeErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.challenge + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class ChallengeErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/market/Market.kt b/src/main/kotlin/team/cklob/arena/domain/market/Market.kt similarity index 98% rename from src/main/kotlin/team/cklob/arena/market/Market.kt rename to src/main/kotlin/team/cklob/arena/domain/market/Market.kt index 2b3d8f6..b59a365 100644 --- a/src/main/kotlin/team/cklob/arena/market/Market.kt +++ b/src/main/kotlin/team/cklob/arena/domain/market/Market.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.market +package team.cklob.arena.domain.market import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt similarity index 93% rename from src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt rename to src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt index d07e8d7..adba881 100644 --- a/src/main/kotlin/team/cklob/arena/recommendation/Recommendation.kt +++ b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.recommendation +package team.cklob.arena.domain.recommendation import jakarta.persistence.Column import jakarta.persistence.Entity @@ -14,8 +14,8 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.Table import org.hibernate.annotations.CreationTimestamp import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.challenge.Challenge -import team.cklob.arena.market.Symbol +import team.cklob.arena.domain.challenge.Challenge +import team.cklob.arena.domain.market.Symbol import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt new file mode 100644 index 0000000..d019f65 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/recommendation/RecommendationErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.recommendation + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class RecommendationErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/trading/Trading.kt b/src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt similarity index 96% rename from src/main/kotlin/team/cklob/arena/trading/Trading.kt rename to src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt index 960cbe5..a93db99 100644 --- a/src/main/kotlin/team/cklob/arena/trading/Trading.kt +++ b/src/main/kotlin/team/cklob/arena/domain/trading/Trading.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.trading +package team.cklob.arena.domain.trading import jakarta.persistence.Column import jakarta.persistence.Entity @@ -16,7 +16,7 @@ import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import org.hibernate.annotations.CreationTimestamp import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.challenge.ChallengeParticipant +import team.cklob.arena.domain.challenge.ChallengeParticipant import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt new file mode 100644 index 0000000..4b451f4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/trading/TradingErrorCode.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.trading + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class TradingErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + INSUFFICIENT_BALANCE(HttpStatus.CONFLICT, "잔액이 부족합니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/user/User.kt b/src/main/kotlin/team/cklob/arena/domain/user/User.kt similarity index 98% rename from src/main/kotlin/team/cklob/arena/user/User.kt rename to src/main/kotlin/team/cklob/arena/domain/user/User.kt index e2614e5..25223f8 100644 --- a/src/main/kotlin/team/cklob/arena/user/User.kt +++ b/src/main/kotlin/team/cklob/arena/domain/user/User.kt @@ -1,4 +1,4 @@ -package team.cklob.arena.user +package team.cklob.arena.domain.user import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt b/src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt new file mode 100644 index 0000000..a59181d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/config/OpenApiConfig.kt @@ -0,0 +1,32 @@ +package team.cklob.arena.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.ObjectSchema +import io.swagger.v3.oas.models.media.StringSchema +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import team.cklob.arena.global.exception.CommonErrorCode + +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI().components( + Components() + .addSecuritySchemes( + "bearerAuth", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"), + ).addSchemas( + "CommonErrorResponse", + ObjectSchema() + .addProperty("code", StringSchema().example(CommonErrorCode.INVALID_REQUEST.code)) + .addProperty("message", StringSchema().example(CommonErrorCode.INVALID_REQUEST.message)) + .addProperty("data", ObjectSchema().nullable(true)), + ), + ) +} diff --git a/src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt new file mode 100644 index 0000000..2e30f13 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/exception/CommonErrorCode.kt @@ -0,0 +1,15 @@ +package team.cklob.arena.global.exception + +import org.springframework.http.HttpStatus + +enum class CommonErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + MALFORMED_JSON(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "요청 값의 형식이 올바르지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt new file mode 100644 index 0000000..a8ca8dd --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/exception/ErrorCode.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.global.exception + +import org.springframework.http.HttpStatus + +interface ErrorCode { + val status: HttpStatus + val message: String + val code: String + get() = (this as Enum<*>).name +} diff --git a/src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt b/src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt new file mode 100644 index 0000000..0eedb16 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/exception/ExpectedException.kt @@ -0,0 +1,6 @@ +package team.cklob.arena.global.exception + +class ExpectedException( + val errorCode: ErrorCode, + messageOverride: String? = null, +) : RuntimeException(messageOverride ?: errorCode.message) diff --git a/src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..467efdb --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,90 @@ +package team.cklob.arena.global.exception + +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import jakarta.validation.ConstraintViolationException +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.servlet.NoHandlerFoundException +import org.springframework.web.servlet.resource.NoResourceFoundException +import team.cklob.arena.global.response.CommonApiResponse +import team.cklob.arena.global.response.FieldErrorDetail +import team.cklob.arena.global.response.ValidationErrorData + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(ExpectedException::class) + fun handleExpectedException(exception: ExpectedException): ResponseEntity> = + response(exception.errorCode, exception.message ?: exception.errorCode.message) + + @ExceptionHandler(MethodArgumentNotValidException::class, BindException::class) + fun handleValidationException(exception: BindException): ResponseEntity> { + val fieldErrors = + exception.bindingResult.fieldErrors.map { + FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") + } + + return response( + errorCode = CommonErrorCode.INVALID_REQUEST, + data = ValidationErrorData(fieldErrors), + ) + } + + @ExceptionHandler(ConstraintViolationException::class) + fun handleConstraintViolation(exception: ConstraintViolationException): ResponseEntity> = + response( + errorCode = CommonErrorCode.INVALID_REQUEST, + data = + ValidationErrorData( + exception.constraintViolations.map { + FieldErrorDetail(field = it.propertyPath.toString(), reason = it.message) + }, + ), + ) + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ResponseEntity> = + response( + if (exception.cause is InvalidFormatException) { + CommonErrorCode.INVALID_TYPE_VALUE + } else { + CommonErrorCode.MALFORMED_JSON + }, + ) + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity> = + response(CommonErrorCode.INVALID_TYPE_VALUE) + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleMethodNotSupported(exception: HttpRequestMethodNotSupportedException): ResponseEntity> = + response(CommonErrorCode.METHOD_NOT_ALLOWED) + + @ExceptionHandler(NoHandlerFoundException::class, NoResourceFoundException::class) + fun handleNotFound(exception: Exception): ResponseEntity> { + return response(CommonErrorCode.RESOURCE_NOT_FOUND) + } + + @ExceptionHandler(Exception::class) + fun handleUnexpectedException(exception: Exception): ResponseEntity> { + log.error("Unhandled exception", exception) + return response(CommonErrorCode.INTERNAL_SERVER_ERROR) + } + + private fun response( + errorCode: ErrorCode, + message: String = errorCode.message, + data: Any? = null, + ): ResponseEntity> = + ResponseEntity + .status(errorCode.status) + .body(CommonApiResponse.error(errorCode, message, data)) +} diff --git a/src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt b/src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt new file mode 100644 index 0000000..d0570d2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/response/ApiResponse.kt @@ -0,0 +1,38 @@ +package team.cklob.arena.global.response + +import team.cklob.arena.global.exception.ErrorCode + +data class CommonApiResponse( + val code: String, + val message: String, + val data: T? = null, +) { + companion object { + fun success(data: T): CommonApiResponse = + CommonApiResponse( + code = "SUCCESS", + message = "성공했습니다.", + data = data, + ) + + fun error( + errorCode: ErrorCode, + message: String = errorCode.message, + data: Any? = null, + ): CommonApiResponse = + CommonApiResponse( + code = errorCode.code, + message = message, + data = data, + ) + } +} + +data class FieldErrorDetail( + val field: String, + val reason: String, +) + +data class ValidationErrorData( + val fieldErrors: List, +) diff --git a/src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt b/src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt new file mode 100644 index 0000000..853ba5c --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/response/CommonResponseAdvice.kt @@ -0,0 +1,34 @@ +package team.cklob.arena.global.response + +import org.springframework.core.MethodParameter +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice + +@RestControllerAdvice +class CommonResponseAdvice : ResponseBodyAdvice { + override fun supports( + returnType: MethodParameter, + converterType: Class>, + ): Boolean = + returnType.parameterType != String::class.java && + !ResponseEntity::class.java.isAssignableFrom(returnType.parameterType) + + override fun beforeBodyWrite( + body: Any?, + returnType: MethodParameter, + selectedContentType: MediaType, + selectedConverterType: Class>, + request: ServerHttpRequest, + response: ServerHttpResponse, + ): Any? = + if (body is CommonApiResponse<*>) { + body + } else { + CommonApiResponse.success(body) + } +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..25cc033 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,51 @@ +package team.cklob.arena.global.security + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtException +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val securityErrorHandler: SecurityErrorHandler, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val authorization = request.getHeader(HttpHeaders.AUTHORIZATION) + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response) + return + } + + val token = authorization.removePrefix(BEARER_PREFIX) + if (token.isBlank()) { + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) + return + } + + try { + val userId = jwtTokenProvider.getUserId(token) + val authentication = UsernamePasswordAuthenticationToken(userId, null, emptyList()) + SecurityContextHolder.getContext().authentication = authentication + filterChain.doFilter(request, response) + } catch (exception: ExpiredJwtException) { + securityErrorHandler.write(response, SecurityErrorCode.EXPIRED_TOKEN) + } catch (exception: JwtException) { + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) + } catch (exception: IllegalArgumentException) { + securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) + } + } + + companion object { + private const val BEARER_PREFIX = "Bearer " + } +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt new file mode 100644 index 0000000..3ee743d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtProperties.kt @@ -0,0 +1,15 @@ +package team.cklob.arena.global.security + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated +import java.time.Duration + +@Validated +@ConfigurationProperties("security.jwt") +data class JwtProperties( + /** Base64-encoded secret with at least 32 bytes before encoding. */ + @field:NotBlank + val secret: String, + val accessTokenExpiration: Duration, +) diff --git a/src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt new file mode 100644 index 0000000..1573cb3 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtTokenProvider.kt @@ -0,0 +1,36 @@ +package team.cklob.arena.global.security + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import java.time.Instant +import java.util.Date +import javax.crypto.SecretKey + +class JwtTokenProvider( + private val jwtProperties: JwtProperties, +) { + fun createAccessToken(userId: Long): String { + val now = Instant.now() + return Jwts + .builder() + .subject(userId.toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(jwtProperties.accessTokenExpiration))) + .signWith(signingKey()) + .compact() + } + + fun getUserId(token: String): Long = parseClaims(token).subject.toLong() + + private fun parseClaims(token: String): Claims = + Jwts + .parser() + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .payload + + private fun signingKey(): SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.secret)) +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt new file mode 100644 index 0000000..ee9ec7f --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt @@ -0,0 +1,47 @@ +package team.cklob.arena.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +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.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig { + @Bean + fun jwtTokenProvider(jwtProperties: JwtProperties): JwtTokenProvider = JwtTokenProvider(jwtProperties) + + @Bean + fun securityErrorHandler(objectMapper: ObjectMapper): SecurityErrorHandler = SecurityErrorHandler(objectMapper) + + @Bean + fun securityFilterChain( + http: HttpSecurity, + jwtTokenProvider: JwtTokenProvider, + securityErrorHandler: SecurityErrorHandler, + ): SecurityFilterChain = + http + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { + it.authenticationEntryPoint(securityErrorHandler) + it.accessDeniedHandler(securityErrorHandler) + }.authorizeHttpRequests { + it.requestMatchers( + "/auth/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health", + "/error", + ).permitAll() + it.anyRequest().authenticated() + }.addFilterBefore( + JwtAuthenticationFilter(jwtTokenProvider, securityErrorHandler), + UsernamePasswordAuthenticationFilter::class.java, + ).build() +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt new file mode 100644 index 0000000..13aacb0 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorCode.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.global.security + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class SecurityErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt new file mode 100644 index 0000000..11b4afd --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityErrorHandler.kt @@ -0,0 +1,41 @@ +package team.cklob.arena.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.AccessDeniedHandler +import team.cklob.arena.global.exception.ErrorCode +import team.cklob.arena.global.response.CommonApiResponse + +class SecurityErrorHandler( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint, AccessDeniedHandler { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: org.springframework.security.core.AuthenticationException, + ) { + write(response, SecurityErrorCode.UNAUTHORIZED) + } + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + write(response, SecurityErrorCode.FORBIDDEN) + } + + fun write( + response: HttpServletResponse, + errorCode: ErrorCode, + ) { + response.status = errorCode.status.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + objectMapper.writeValue(response.writer, CommonApiResponse.error(errorCode)) + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 43959e8..6d40c5e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,9 @@ spring: application: name: arena + +security: + jwt: + # Base64-encoded secret with at least 32 bytes before encoding. + secret: ${JWT_SECRET} + access-token-expiration: PT15M diff --git a/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt new file mode 100644 index 0000000..5d048e0 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt @@ -0,0 +1,115 @@ +package team.cklob.arena.global.security + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.extensions.spring.SpringExtension +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +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.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootTest +@AutoConfigureMockMvc +@Import(SecurityIntegrationTest.TestApi::class) +class SecurityIntegrationTest( + private val mockMvc: MockMvc, + private val jwtTokenProvider: JwtTokenProvider, +) : DescribeSpec({ + extension(SpringExtension) + + describe("공통 응답 및 보안 설정") { + it("Bearer가 아닌 Authorization 헤더는 공개 API를 차단하지 않는다") { + mockMvc.get("/auth/test") { + header("Authorization", "Basic ignored") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + } + } + + it("공개 API 응답을 공통 형식으로 감싼다") { + mockMvc.get("/auth/test") { + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + jsonPath("$.data.value") { value("public") } + } + } + + it("인증 없이 보호 API를 호출하면 공통 401 응답을 반환한다") { + mockMvc.get("/test/protected") { + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isUnauthorized() } + jsonPath("$.code") { value("UNAUTHORIZED") } + } + } + + it("유효한 Bearer JWT로 보호 API를 호출할 수 있다") { + mockMvc.get("/test/protected") { + header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(1L)}") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.code") { value("SUCCESS") } + jsonPath("$.data.userId") { value(1) } + } + } + + it("변조된 JWT는 공통 401 응답을 반환한다") { + mockMvc.get("/test/protected") { + header("Authorization", "Bearer invalid-token") + accept = MediaType.APPLICATION_JSON + }.andExpect { + status { isUnauthorized() } + jsonPath("$.code") { value("INVALID_TOKEN") } + } + } + + it("검증 오류를 필드 오류 목록으로 반환한다") { + mockMvc.post("/auth/test") { + contentType = MediaType.APPLICATION_JSON + content = """{"name":""}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.code") { value("INVALID_REQUEST") } + jsonPath("$.data.fieldErrors[0].field") { value("name") } + } + } + } + }) { + override fun extensions() = listOf(SpringExtension) + + @TestConfiguration + @RestController + @RequestMapping + class TestApi { + @GetMapping("/auth/test") + fun publicApi() = mapOf("value" to "public") + + @GetMapping("/test/protected") + fun protectedApi(authentication: org.springframework.security.core.Authentication) = mapOf("userId" to authentication.principal) + + @PostMapping("/auth/test") + fun validate( + @Valid @RequestBody request: TestRequest, + ) = mapOf("name" to request.name) + } + + data class TestRequest( + @field:NotBlank + val name: String, + ) +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..699f972 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,4 @@ +security: + jwt: + secret: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + access-token-expiration: PT15M