Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions src/main/kotlin/team/cklob/arena/ArenaApplication.kt
Original file line number Diff line number Diff line change
@@ -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<String>) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/team/cklob/arena/challenge/ChallengeErrorCode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package team.cklob.arena.challenge

import org.springframework.http.HttpStatus
import team.cklob.arena.common.ErrorCode

enum class ChallengeErrorCode(
override val status: HttpStatus,
override val message: String,
) : ErrorCode {
CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."),
}
36 changes: 36 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/ApiResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package team.cklob.arena.common

data class CommonApiResponse<T>(
val code: String,
val message: String,
val data: T? = null,
) {
companion object {
fun <T> success(data: T): CommonApiResponse<T> =
CommonApiResponse(
code = "SUCCESS",
message = "성공했습니다.",
data = data,
)

fun error(
errorCode: ErrorCode,
message: String = errorCode.message,
data: Any? = null,
): CommonApiResponse<Any> =
CommonApiResponse(
code = errorCode.code,
message = message,
data = data,
)
}
}

data class FieldErrorDetail(
val field: String,
val reason: String,
)

data class ValidationErrorData(
val fieldErrors: List<FieldErrorDetail>,
)
15 changes: 15 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/CommonErrorCode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package team.cklob.arena.common

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, "서버 오류가 발생했습니다."),
}
34 changes: 34 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/CommonResponseAdvice.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package team.cklob.arena.common

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<Any> {
override fun supports(
returnType: MethodParameter,
converterType: Class<out HttpMessageConverter<*>>,
): Boolean =
returnType.parameterType != String::class.java &&
!ResponseEntity::class.java.isAssignableFrom(returnType.parameterType)

override fun beforeBodyWrite(
body: Any?,
returnType: MethodParameter,
selectedContentType: MediaType,
selectedConverterType: Class<out HttpMessageConverter<*>>,
request: ServerHttpRequest,
response: ServerHttpResponse,
): Any? =
if (body is CommonApiResponse<*>) {
body
} else {
CommonApiResponse.success(body)
}
}
10 changes: 10 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/ErrorCode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package team.cklob.arena.common

import org.springframework.http.HttpStatus

interface ErrorCode {
val status: HttpStatus
val message: String
val code: String
get() = (this as Enum<*>).name
}
6 changes: 6 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/ExpectedException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package team.cklob.arena.common

class ExpectedException(
val errorCode: ErrorCode,
messageOverride: String? = null,
) : RuntimeException(messageOverride ?: errorCode.message)
87 changes: 87 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/GlobalExceptionHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package team.cklob.arena.common

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

@RestControllerAdvice
class GlobalExceptionHandler {
private val log = LoggerFactory.getLogger(javaClass)

@ExceptionHandler(ExpectedException::class)
fun handleExpectedException(exception: ExpectedException): ResponseEntity<CommonApiResponse<Any>> =
response(exception.errorCode, exception.message ?: exception.errorCode.message)

@ExceptionHandler(MethodArgumentNotValidException::class, BindException::class)
fun handleValidationException(exception: BindException): ResponseEntity<CommonApiResponse<Any>> {
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<CommonApiResponse<Any>> =
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<CommonApiResponse<Any>> =
response(
if (exception.cause is InvalidFormatException) {
CommonErrorCode.INVALID_TYPE_VALUE
} else {
CommonErrorCode.MALFORMED_JSON
},
)

@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity<CommonApiResponse<Any>> =
response(CommonErrorCode.INVALID_TYPE_VALUE)

@ExceptionHandler(HttpRequestMethodNotSupportedException::class)
fun handleMethodNotSupported(exception: HttpRequestMethodNotSupportedException): ResponseEntity<CommonApiResponse<Any>> =
response(CommonErrorCode.METHOD_NOT_ALLOWED)

@ExceptionHandler(NoHandlerFoundException::class, NoResourceFoundException::class)
fun handleNotFound(exception: Exception): ResponseEntity<CommonApiResponse<Any>> {
return response(CommonErrorCode.RESOURCE_NOT_FOUND)
}

@ExceptionHandler(Exception::class)
fun handleUnexpectedException(exception: Exception): ResponseEntity<CommonApiResponse<Any>> {
log.error("Unhandled exception", exception)
return response(CommonErrorCode.INTERNAL_SERVER_ERROR)
}

private fun response(
errorCode: ErrorCode,
message: String = errorCode.message,
data: Any? = null,
): ResponseEntity<CommonApiResponse<Any>> =
ResponseEntity
.status(errorCode.status)
.body(CommonApiResponse.error(errorCode, message, data))
}
31 changes: 31 additions & 0 deletions src/main/kotlin/team/cklob/arena/common/OpenApiConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package team.cklob.arena.common

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

@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)),
),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package team.cklob.arena.recommendation

import org.springframework.http.HttpStatus
import team.cklob.arena.common.ErrorCode

enum class RecommendationErrorCode(
override val status: HttpStatus,
override val message: String,
) : ErrorCode {
RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package team.cklob.arena.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 "
}
}
15 changes: 15 additions & 0 deletions src/main/kotlin/team/cklob/arena/security/JwtProperties.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package team.cklob.arena.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,
)
36 changes: 36 additions & 0 deletions src/main/kotlin/team/cklob/arena/security/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package team.cklob.arena.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))
}
Loading
Loading