feat(ai-character): 관리자 API Phase 1 기반을 추가한다
This commit is contained in:
@@ -142,6 +142,15 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
)
|
||||
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT c FROM ChatCharacter c
|
||||
LEFT JOIN FETCH c.creatorMember
|
||||
WHERE c.id = :id
|
||||
"""
|
||||
)
|
||||
fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter?
|
||||
|
||||
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
||||
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.common
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import io.jsonwebtoken.JwtException
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver
|
||||
import javax.servlet.FilterChain
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() {
|
||||
class ExceptionHandlerFilter(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val apiRequestMatcher: RequestMatcher,
|
||||
private val apiAuthenticationEntryPoint: AuthenticationEntryPoint,
|
||||
private val apiExceptionResolver: HandlerExceptionResolver
|
||||
) : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
@@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
|
||||
try {
|
||||
filterChain.doFilter(request, response)
|
||||
} catch (e: Exception) {
|
||||
if (apiRequestMatcher.matches(request)) {
|
||||
if (isAuthenticationFailure(e)) {
|
||||
val authenticationException = e as? BadCredentialsException
|
||||
?: BadCredentialsException("Authentication failed", e)
|
||||
apiAuthenticationEntryPoint.commence(request, response, authenticationException)
|
||||
} else {
|
||||
val resolved = apiExceptionResolver.resolveException(request, response, null, e)
|
||||
if (resolved == null) throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.status = 401
|
||||
response.contentType = "application/json"
|
||||
response.characterEncoding = "UTF-8"
|
||||
@@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
|
||||
response.writer.write(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAuthenticationFailure(exception: Exception): Boolean {
|
||||
return exception is JwtException ||
|
||||
exception is BadCredentialsException ||
|
||||
(exception is SodaException && exception.messageKey == "common.error.bad_credentials")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,19 @@ package kr.co.vividnext.sodalive.configs
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.WebSecurity
|
||||
@@ -17,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.access.AccessDeniedHandler
|
||||
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler
|
||||
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||
import org.springframework.web.HttpRequestHandler
|
||||
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -35,25 +49,94 @@ class SecurityConfig(
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun webSecurityCustomizer(): WebSecurityCustomizer {
|
||||
fun webSecurityCustomizer(
|
||||
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler
|
||||
): WebSecurityCustomizer {
|
||||
return WebSecurityCustomizer { web: WebSecurity ->
|
||||
web
|
||||
.requestRejectedHandler(aiCharacterAdminSecurityErrorHandler)
|
||||
.ignoring()
|
||||
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter {
|
||||
return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun aiCharacterAdminSecurityErrorHandler(
|
||||
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||
): AiCharacterAdminSecurityErrorHandler {
|
||||
return AiCharacterAdminSecurityErrorHandler(
|
||||
responseWriter,
|
||||
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH),
|
||||
WebConfig.createAiCharacterAdminCorsConfiguration()
|
||||
)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun aiCharacterAdminExceptionHandler(
|
||||
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||
): AiCharacterAdminExceptionHandler {
|
||||
return AiCharacterAdminExceptionHandler(
|
||||
responseWriter,
|
||||
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
|
||||
)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun aiCharacterAdminFallbackHandlerMapping(
|
||||
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||
): SimpleUrlHandlerMapping {
|
||||
val notFoundHandler = HttpRequestHandler { request, response ->
|
||||
responseWriter.write(
|
||||
request,
|
||||
response,
|
||||
HttpStatus.NOT_FOUND,
|
||||
"common.error.invalid_request"
|
||||
)
|
||||
}
|
||||
return SimpleUrlHandlerMapping(
|
||||
mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler),
|
||||
Ordered.LOWEST_PRECEDENCE - 2
|
||||
).apply {
|
||||
setCorsConfigurations(
|
||||
mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun filterChain(
|
||||
http: HttpSecurity,
|
||||
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler,
|
||||
aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler
|
||||
): SecurityFilterChain {
|
||||
val jwtFilter = JwtFilter(tokenProvider)
|
||||
val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
|
||||
val authenticationEntryPoints = linkedMapOf<RequestMatcher, AuthenticationEntryPoint>(
|
||||
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
|
||||
)
|
||||
val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply {
|
||||
setDefaultEntryPoint(authenticationEntryPoint)
|
||||
}
|
||||
val accessDeniedHandlers = linkedMapOf<RequestMatcher, AccessDeniedHandler>(
|
||||
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
|
||||
)
|
||||
val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler(
|
||||
accessDeniedHandlers,
|
||||
accessDeniedHandler
|
||||
)
|
||||
|
||||
return http
|
||||
.cors()
|
||||
.and()
|
||||
.csrf().disable()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.authenticationEntryPoint(delegatingAuthenticationEntryPoint)
|
||||
.accessDeniedHandler(delegatingAccessDeniedHandler)
|
||||
.and()
|
||||
.headers()
|
||||
.frameOptions()
|
||||
@@ -63,7 +146,15 @@ class SecurityConfig(
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
|
||||
.addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java)
|
||||
.addFilterBefore(
|
||||
ExceptionHandlerFilter(
|
||||
objectMapper,
|
||||
aiCharacterAdminRequestMatcher,
|
||||
aiCharacterAdminSecurityErrorHandler,
|
||||
aiCharacterAdminExceptionHandler
|
||||
),
|
||||
JwtFilter::class.java
|
||||
)
|
||||
.authorizeRequests()
|
||||
.antMatchers("/member/check/email").permitAll()
|
||||
.antMatchers("/member/check/nickname").permitAll()
|
||||
@@ -108,10 +199,20 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
||||
.antMatchers(AI_CHARACTER_ADMIN_PATH)
|
||||
.access(
|
||||
"hasRole('ADMIN') and " +
|
||||
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
|
||||
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN"
|
||||
)
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs
|
||||
import kr.co.vividnext.sodalive.common.CountryInterceptor
|
||||
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
@@ -18,15 +19,49 @@ class WebConfig(
|
||||
}
|
||||
|
||||
override fun addCorsMappings(registry: CorsRegistry) {
|
||||
listOf("/admin/member/login", "/member/logout").forEach { path ->
|
||||
registry.addMapping(path)
|
||||
.allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray())
|
||||
.allowedMethods("*")
|
||||
.allowCredentials(true)
|
||||
}
|
||||
|
||||
registry.addMapping("/api/v2/admin/ai-characters/**")
|
||||
.allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray())
|
||||
.allowedMethods("*")
|
||||
.allowCredentials(true)
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
"http://localhost:8888",
|
||||
"https://creator.sodalive.net",
|
||||
"https://test-creator.sodalive.net",
|
||||
"https://test-admin.sodalive.net",
|
||||
"https://admin.sodalive.net"
|
||||
)
|
||||
.allowedOrigins(*ALLOWED_ORIGINS.toTypedArray())
|
||||
.allowedMethods("*")
|
||||
.allowCredentials(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ALLOWED_ORIGINS = listOf(
|
||||
"http://localhost:8888",
|
||||
"https://creator.sodalive.net",
|
||||
"https://test-creator.sodalive.net",
|
||||
"https://test-admin.sodalive.net",
|
||||
"https://admin.sodalive.net"
|
||||
)
|
||||
|
||||
private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf(
|
||||
"http://localhost:8888",
|
||||
"https://test-character-admin.sodalive.net",
|
||||
"https://character-admin.sodalive.net"
|
||||
)
|
||||
|
||||
private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS =
|
||||
(ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct()
|
||||
|
||||
internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration {
|
||||
return CorsConfiguration().apply {
|
||||
applyPermitDefaultValues()
|
||||
allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS
|
||||
allowedMethods = listOf(CorsConfiguration.ALL)
|
||||
allowCredentials = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +83,21 @@ class TokenProvider(
|
||||
.parseClaimsJws(token)
|
||||
.body
|
||||
|
||||
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) }
|
||||
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong())
|
||||
val memberId = claims.subject?.toLongOrNull()
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
val authorityNames = (claims[AUTHORITIES_KEY] as? String)
|
||||
?.split(",")
|
||||
?.takeIf { names -> names.all { it.isNotBlank() } }
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
val authorities = authorityNames.map { SimpleGrantedAuthority(it) }
|
||||
val memberToken = tokenRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (!memberToken.tokenSet.contains(token)) {
|
||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
}
|
||||
|
||||
val member = repository.findByIdOrNull(id = claims.subject.toLong())
|
||||
val member = repository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
val principal = MemberAdapter(member)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AiCharacterAdminTargetResolver(
|
||||
private val chatCharacterRepository: ChatCharacterRepository
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun resolve(characterId: Long): AiCharacterAdminTarget {
|
||||
val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId)
|
||||
?: throw invalidTarget()
|
||||
val creatorMember = chatCharacter.creatorMember
|
||||
|
||||
if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
|
||||
throw invalidTarget()
|
||||
}
|
||||
|
||||
return AiCharacterAdminTarget(
|
||||
characterId = characterId,
|
||||
chatCharacter = chatCharacter,
|
||||
creatorMember = creatorMember
|
||||
)
|
||||
}
|
||||
|
||||
private fun invalidTarget(): AiCharacterAdminApiException {
|
||||
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
data class AiCharacterAdminTarget(
|
||||
val characterId: Long,
|
||||
val chatCharacter: ChatCharacter,
|
||||
val creatorMember: Member
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
class AiCharacterAdminApiException(
|
||||
val status: HttpStatus,
|
||||
val messageKey: String
|
||||
) : RuntimeException(messageKey)
|
||||
@@ -0,0 +1,35 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
class AiCharacterAdminErrorResponseWriter(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val messageSource: SodaMessageSource
|
||||
) {
|
||||
fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse<Any> {
|
||||
val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))
|
||||
val message = messageSource.getMessage(messageKey, lang)
|
||||
?: messageSource.getMessage("common.error.unknown", lang)
|
||||
return ApiResponse.error(message = message)
|
||||
}
|
||||
|
||||
fun write(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
status: HttpStatus,
|
||||
messageKey: String
|
||||
) {
|
||||
response.status = status.value()
|
||||
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
response.characterEncoding = Charsets.UTF_8.name()
|
||||
response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||
import org.springframework.validation.BindException
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.MissingPathVariableException
|
||||
import org.springframework.web.bind.ServletRequestBindingException
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||
import org.springframework.web.multipart.MultipartException
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver
|
||||
import org.springframework.web.servlet.ModelAndView
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
class AiCharacterAdminExceptionHandler(
|
||||
private val responseWriter: AiCharacterAdminErrorResponseWriter,
|
||||
private val requestMatcher: RequestMatcher
|
||||
) : HandlerExceptionResolver, Ordered {
|
||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
|
||||
|
||||
override fun resolveException(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any?,
|
||||
exception: Exception
|
||||
): ModelAndView? {
|
||||
if (!requestMatcher.matches(request)) return null
|
||||
|
||||
val error = resolveError(exception)
|
||||
if (error.status.is5xxServerError) {
|
||||
logger.error("Unexpected AI character admin API error", exception)
|
||||
} else {
|
||||
logger.debug("AI character admin API request error", exception)
|
||||
}
|
||||
writeStandardHeaders(request, response, exception)
|
||||
responseWriter.write(request, response, error.status, error.messageKey)
|
||||
return ModelAndView()
|
||||
}
|
||||
|
||||
private fun resolveError(exception: Exception): ResolvedError {
|
||||
return when (exception) {
|
||||
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
|
||||
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
|
||||
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE)
|
||||
is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
|
||||
is BindException,
|
||||
is HttpMessageNotReadableException,
|
||||
is MethodArgumentNotValidException,
|
||||
is MethodArgumentTypeMismatchException,
|
||||
is ServletRequestBindingException,
|
||||
is MultipartException,
|
||||
is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST)
|
||||
else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeStandardHeaders(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
exception: Exception
|
||||
) {
|
||||
when (exception) {
|
||||
is HttpRequestMethodNotSupportedException -> {
|
||||
exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) }
|
||||
}
|
||||
is HttpMediaTypeNotSupportedException -> {
|
||||
if (exception.supportedMediaTypes.isNotEmpty()) {
|
||||
val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes)
|
||||
response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes)
|
||||
if (request.method == HttpMethod.PATCH.name) {
|
||||
response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidRequest(status: HttpStatus): ResolvedError {
|
||||
return ResolvedError(status, "common.error.invalid_request")
|
||||
}
|
||||
|
||||
private data class ResolvedError(
|
||||
val status: HttpStatus,
|
||||
val messageKey: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.core.AuthenticationException
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.access.AccessDeniedHandler
|
||||
import org.springframework.security.web.firewall.DefaultRequestRejectedHandler
|
||||
import org.springframework.security.web.firewall.RequestRejectedException
|
||||
import org.springframework.security.web.firewall.RequestRejectedHandler
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.DefaultCorsProcessor
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletRequestWrapper
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
class AiCharacterAdminSecurityErrorHandler(
|
||||
private val responseWriter: AiCharacterAdminErrorResponseWriter,
|
||||
private val aiCharacterAdminRequestMatcher: RequestMatcher,
|
||||
private val aiCharacterAdminCorsConfiguration: CorsConfiguration
|
||||
) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler {
|
||||
private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler()
|
||||
private val corsProcessor = DefaultCorsProcessor()
|
||||
|
||||
override fun commence(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
authException: AuthenticationException
|
||||
) {
|
||||
responseWriter.write(
|
||||
request = request,
|
||||
response = response,
|
||||
status = HttpStatus.UNAUTHORIZED,
|
||||
messageKey = "common.error.bad_credentials"
|
||||
)
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
accessDeniedException: AccessDeniedException
|
||||
) {
|
||||
responseWriter.write(
|
||||
request = request,
|
||||
response = response,
|
||||
status = HttpStatus.FORBIDDEN,
|
||||
messageKey = "common.error.access_denied"
|
||||
)
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
requestRejectedException: RequestRejectedException
|
||||
) {
|
||||
if (!aiCharacterAdminRequestMatcher.matches(request)) {
|
||||
defaultRequestRejectedHandler.handle(request, response, requestRejectedException)
|
||||
return
|
||||
}
|
||||
|
||||
// Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks.
|
||||
val corsRequest = if (HttpMethod.resolve(request.method) == null) {
|
||||
object : HttpServletRequestWrapper(request) {
|
||||
override fun getMethod(): String = HttpMethod.GET.name
|
||||
}
|
||||
} else {
|
||||
request
|
||||
}
|
||||
if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) {
|
||||
return
|
||||
}
|
||||
|
||||
responseWriter.write(
|
||||
request = request,
|
||||
response = response,
|
||||
status = HttpStatus.BAD_REQUEST,
|
||||
messageKey = "common.error.invalid_request"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user