Development/Spring Boot3 (Kotlin)

[Kotlin][SpringBoot3] Spring Security 초기 설정 예시

Tradgineer 2023. 12. 8. 11:25

 

1. 작업 환경

 

Spring Boot 3.1.3

 

// vim build.gradle.kts

// ...

dependencies {

    // ...

    // spring security
    implementation("org.springframework.boot:spring-boot-starter-security")
    testImplementation("org.springframework.security:spring-security-test")
    
    // ...
    
}

// ...

 

 

 

 

 

2. RoleEnum

 

// vim RoleEnum.kt

package com.dev.test01.product.client

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority

enum class RoleEnum(val id: String) {
    NON("ROLE_NON"),
    SIGNING_UP("ROLE_SIGNING_UP"),
    SIGNED_UP("ROLE_SIGNED_UP"),
    BASIC_PLAN("ROLE_BASIC_PLAN"),
    STANDARD_PLAN("ROLE_STANDARD_PLAN"),
    ESSENTIAL_PLAN("ROLE_ESSENTIAL_PLAN"),
    PLUS_PLAN("ROLE_PLUS_PLAN"),
    PREMIUM_PLAN("ROLE_PREMIUM_PLAN"),
    QA("ROLE_QA"),
    DEV("ROLE_DEV"),
    ADMIN("ROLE_ADMIN");

    fun toGrantedAuthority(): GrantedAuthority {
        return SimpleGrantedAuthority(id)
    }
}

 

 

 

 

 

3. ErrorResponse

 

// vim ErrorResponse.kt

package com.dev.test01.security.entities

import org.springframework.http.HttpStatus

data class ErrorResponse (
    private val status: HttpStatus? = null,
    val message: String? = null
)

 

 

 

 

 

 

 

4. security config 파일 기본 소스코드 작성

 

 - csrf(Cross site Request forgery) 설정 disable

 - h2-console 화면을 편하게 접근해 사용하기 위해 해당 옵션들을 disable

 - 프로젝트 view 구성에서 보았듯이 자원 요청 별 권한 설정을 하였습니다. 위에서부터 순서대로 h2-console을 사용하기 위한 설정(스프링 시큐리티 6.0.0 이상 버전 부터 저 설정을 하지 않으면 h2-console에 들어갈 수 없었습니다..) 메인화면과 로그인 및 회원가입 화면은 권한에 상관없이 접근할 수 있어야 하기에 permitAll로 모든 접근을 허용하였습니다. posts 관련 요청은 로그인 인증을 하여 USER 권한을 획득한 사용자만 접근 할 수 있기에 hasRole(Role.USER.name()) 설정을 하였습니다. admin 관련 요청은 ADMIN 권한이 있어야 접근이 가능하기에 hasRole(Role.ADMIN.name()) 설정을 하였습니다. 참고로 @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션으로 @EnableWebSecurity 애너테이션을 사용하면 내부적으로 SpringSecurityFilterChain이 동작하여 URL 필터가 적용됩니다. 이제 domain패키지 하위에 member 패키지를 생성하고 그 밑에 Role Enum을 만들어 줍니다.

// vim SecurityConfig.kt

package com.dev.test01.security

import com.dev.test01.product.client.RoleEnum
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.annotation.web.configurers.CsrfConfigurer
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.util.matcher.AntPathRequestMatcher

@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    @Throws(Exception::class)
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf {
                csrfConfig: CsrfConfigurer<HttpSecurity> -> csrfConfig.disable()
            } // 1번
            .headers { headerConfig: HeadersConfigurer<HttpSecurity?> ->
                headerConfig.frameOptions(
                    { frameOptionsConfig -> frameOptionsConfig.disable() }
                )
            } // 2번
            .authorizeHttpRequests { authorizeRequests ->
                authorizeRequests
                    .requestMatchers(AntPathRequestMatcher("/h2-console/**")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/swagger-ui/**")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/v3/api-docs/**")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/swagger-resources/**")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/favicon.ico")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/error")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/api/v1/client/**")).hasRole(RoleEnum.BASIC_PLAN.name)
                    .requestMatchers(AntPathRequestMatcher("/api/v1/posts/**")).hasRole(RoleEnum.BASIC_PLAN.name)
                    .requestMatchers(AntPathRequestMatcher("/api/v1/admins/**")).hasRole(RoleEnum.ADMIN.name)
                    .anyRequest().authenticated()
            } // 3번
        return http.build()
    }
}

 

 

 

 

 

5. 서버 가동

 

 - permitAll 해준 h2, swagger 정상 접속 확인

 - 접근 허용하지 않은 경로의 경우 403 에러 확인시 정상 적용됨

 

 로그인을 하지 않았기 때문에 401(Unauthorized)인증 관련 HTTP 상태 코드가 나와야하는데, 403(Forbidden) 권한 관련 상태코드를 응답하는 것을 확인할 수 있다.

 Spring security에서는 401(Unauthorized) 관련 인증 예외처리를 해주지 않을 경우, 인가 예외 처리로 응답시키기 때문이다.

 

 

 

 

 

6. 403, 401 에러 예외처리 포함 소스코드

 

// vim SecurityConfig.kt

package com.dev.test01.security

import com.dev.test01.product.client.RoleEnum
import com.dev.test01.security.entities.ErrorResponse
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.util.matcher.AntPathRequestMatcher


@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    @Throws(Exception::class)
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf {
                csrfConfig: CsrfConfigurer<HttpSecurity> -> csrfConfig.disable()
            } // 1번
            .headers { headerConfig: HeadersConfigurer<HttpSecurity?> ->
                headerConfig.frameOptions(
                    { frameOptionsConfig -> frameOptionsConfig.disable() }
                )
            } // 2번
            .authorizeHttpRequests { authorizeRequests ->
                authorizeRequests
                    .requestMatchers(AntPathRequestMatcher("/h2-console/**")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/swagger-ui/**")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/v3/api-docs/**")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/swagger-resources/**")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/favicon.ico")).permitAll()
//                    .requestMatchers(AntPathRequestMatcher("/error")).permitAll()
                    .requestMatchers(AntPathRequestMatcher("/api/v1/client/**")).hasRole(RoleEnum.BASIC_PLAN.name)
                    .requestMatchers(AntPathRequestMatcher("/api/v1/posts/**")).hasRole(RoleEnum.BASIC_PLAN.name)
                    .requestMatchers(AntPathRequestMatcher("/api/v1/admins/**")).hasRole(RoleEnum.ADMIN.name)
                    .anyRequest().authenticated()
            } // 3번
            .exceptionHandling { exceptionConfig: ExceptionHandlingConfigurer<HttpSecurity?> ->
                exceptionConfig.authenticationEntryPoint(
                    unauthorizedEntryPoint
                ).accessDeniedHandler(accessDeniedHandler)
            } // 401 403 관련 예외처리
        return http.build()
    }

    private val unauthorizedEntryPoint =
        AuthenticationEntryPoint { request: HttpServletRequest?, response: HttpServletResponse, authException: AuthenticationException? ->
            val fail: ErrorResponse = ErrorResponse(
                HttpStatus.UNAUTHORIZED,
                "Spring security unauthorized..."
            )
            response.status = HttpStatus.UNAUTHORIZED.value()
            val json = ObjectMapper().writeValueAsString(fail)
            response.contentType = MediaType.APPLICATION_JSON_VALUE
            val writer = response.writer
            writer.write(json)
            writer.flush()
        }
    
    private val accessDeniedHandler =
        AccessDeniedHandler { request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException? ->
            val fail: ErrorResponse =
                ErrorResponse(HttpStatus.FORBIDDEN, "Spring security forbidden...")
            response.status = HttpStatus.FORBIDDEN.value()
            val json = ObjectMapper().writeValueAsString(fail)
            response.contentType = MediaType.APPLICATION_JSON_VALUE
            val writer = response.writer
            writer.write(json)
            writer.flush()
        }
}

 

 

 

 

 

7. 401, 403 정상 적용 확인