Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
58 changes: 58 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,58 @@
package team.cklob.arena.common

import org.springframework.http.HttpStatus

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 = ErrorCode.SUCCESS.name,
message = ErrorCode.SUCCESS.message,
data = data,
)

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

enum class ErrorCode(
val status: HttpStatus,
val message: String,
) {
SUCCESS(HttpStatus.OK, "성공했습니다."),
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, "요청한 리소스를 찾을 수 없습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
INSUFFICIENT_BALANCE(HttpStatus.CONFLICT, "잔액이 부족합니다."),
CHALLENGE_ALREADY_ENDED(HttpStatus.CONFLICT, "이미 종료된 챌린지입니다."),
RECOMMENDATION_ALREADY_REACTED(HttpStatus.CONFLICT, "이미 반응한 추천입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."),
}

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

data class ValidationErrorData(
val fieldErrors: List<FieldErrorDetail>,
)
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)
}
}
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: Exception): ResponseEntity<CommonApiResponse<Any>> {
val fieldErrors =
when (exception) {
is MethodArgumentNotValidException -> exception.bindingResult.fieldErrors
is BindException -> exception.bindingResult.fieldErrors
else -> emptyList()
}.map { FieldErrorDetail(field = it.field, reason = it.defaultMessage ?: "유효하지 않은 값입니다.") }

return response(
errorCode = ErrorCode.INVALID_REQUEST,
data = ValidationErrorData(fieldErrors),
)
}

@ExceptionHandler(ConstraintViolationException::class)
fun handleConstraintViolation(exception: ConstraintViolationException): ResponseEntity<CommonApiResponse<Any>> =
response(
errorCode = ErrorCode.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) {
ErrorCode.INVALID_TYPE_VALUE
} else {
ErrorCode.MALFORMED_JSON
},
)

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

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

@ExceptionHandler(NoHandlerFoundException::class, NoResourceFoundException::class)
fun handleNotFound(exception: Exception): ResponseEntity<CommonApiResponse<Any>> = response(ErrorCode.RESOURCE_NOT_FOUND)

@ExceptionHandler(Exception::class)
fun handleUnexpectedException(exception: Exception): ResponseEntity<CommonApiResponse<Any>> {
log.error("Unhandled exception", exception)
return response(ErrorCode.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(ErrorCode.INVALID_REQUEST.name))
.addProperty("message", StringSchema().example(ErrorCode.INVALID_REQUEST.message))
.addProperty("data", ObjectSchema().nullable(true)),
),
)
}
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
import team.cklob.arena.common.ErrorCode

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) {
filterChain.doFilter(request, response)
return
}

if (!authorization.startsWith(BEARER_PREFIX) || authorization.length == BEARER_PREFIX.length) {
securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN)
return
}

try {
val userId = jwtTokenProvider.getUserId(authorization.removePrefix(BEARER_PREFIX))
val authentication = UsernamePasswordAuthenticationToken(userId, null, emptyList())
SecurityContextHolder.getContext().authentication = authentication
filterChain.doFilter(request, response)
} catch (exception: ExpiredJwtException) {
securityErrorHandler.write(response, ErrorCode.EXPIRED_TOKEN)
} catch (exception: JwtException) {
securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN)
} catch (exception: IllegalArgumentException) {
securityErrorHandler.write(response, ErrorCode.INVALID_TOKEN)
}
}

companion object {
private const val BEARER_PREFIX = "Bearer "
}
}
14 changes: 14 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,14 @@
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(
@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))
}
46 changes: 46 additions & 0 deletions src/main/kotlin/team/cklob/arena/security/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package team.cklob.arena.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/**",
"/v3/api-docs/**",
"/actuator/health",
"/error",
).permitAll()
it.anyRequest().authenticated()
}.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider, securityErrorHandler),
UsernamePasswordAuthenticationFilter::class.java,
).build()
}
Loading
Loading