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 정상 적용 확인