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 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer 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 @EnableGlobalMethodSecurity(prePostEnabled = true) class SecurityConfig( private val objectMapper: ObjectMapper, private val tokenProvider: TokenProvider, private val accessDeniedHandler: JwtAccessDeniedHandler, private val authenticationEntryPoint: JwtAuthenticationEntryPoint ) { @Bean fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder() } @Bean fun webSecurityCustomizer( aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler ): WebSecurityCustomizer { return WebSecurityCustomizer { web: WebSecurity -> web .requestRejectedHandler(aiCharacterAdminSecurityErrorHandler) .ignoring() .antMatchers("/h2-console/**", "/favicon.ico", "/error") } } @Bean 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( aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler ) val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply { setDefaultEntryPoint(authenticationEntryPoint) } val accessDeniedHandlers = linkedMapOf( aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler ) val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler( accessDeniedHandlers, accessDeniedHandler ) return http .cors() .and() .csrf().disable() .exceptionHandling() .authenticationEntryPoint(delegatingAuthenticationEntryPoint) .accessDeniedHandler(delegatingAccessDeniedHandler) .and() .headers() .frameOptions() .sameOrigin() .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) .addFilterBefore( ExceptionHandlerFilter( objectMapper, aiCharacterAdminRequestMatcher, aiCharacterAdminSecurityErrorHandler, aiCharacterAdminExceptionHandler ), JwtFilter::class.java ) .authorizeRequests() .antMatchers("/member/check/email").permitAll() .antMatchers("/member/check/nickname").permitAll() .antMatchers("/member/signup").permitAll() .antMatchers("/member/signup/v2").permitAll() .antMatchers("/member/login").permitAll() .antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/kakao").permitAll() .antMatchers("/member/login/apple").permitAll() .antMatchers("/member/login/line").permitAll() .antMatchers("/admin/member/login").permitAll() .antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/member/forgot-password").permitAll() .antMatchers("/stplat/terms_of_service").permitAll() .antMatchers("/stplat/privacy_policy").permitAll() .antMatchers("/charge/ads").permitAll() .antMatchers("/v2/audio-content/main/home").permitAll() .antMatchers("/v2/audio-content/main/home/popular-content-by-creator").permitAll() .antMatchers("/v2/audio-content/main/home/content/ranking").permitAll() .antMatchers("/api/home").permitAll() .antMatchers("/api/home/latest-content").permitAll() .antMatchers("/api/home/day-of-week-series").permitAll() .antMatchers("/api/home/content-ranking").permitAll() .antMatchers(HttpMethod.GET, "/api/live").permitAll() .antMatchers(HttpMethod.GET, "/faq").permitAll() .antMatchers(HttpMethod.GET, "/faq/category").permitAll() .antMatchers("/audition").permitAll() .antMatchers("/live/recommend/channel").permitAll() .antMatchers(HttpMethod.GET, "/live/room").permitAll() .antMatchers(HttpMethod.GET, "/event").permitAll() .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll() .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/**" } }