Spring boot에서 권한 Scope 처리를 해보자! (AccessDecisionManager, AccessDecisionVoter)
이직하고 글 처음 올린다.
이직한 회사에서 적응한다고 (핑계..) ...
구현 내용
요구사항
○ 유저를 생성할 때 각 유저별로 권한을 설정할 수가 있다.
○ 권한에는 메뉴별로 읽기 권한, 쓰기 권한 등 간단하게 CRUD 형태로 상세 권한을 설정할 수 있다
구현
○ 각 API에 지정된 권한이 있다는 뜻이므로 DB에서 관리를 해야겠다고 생각했다. (stackoverflow에는 DB로 관리하는 것을 추천하지 않는다..)
○ jwt token에 권한 ID 값을 넣어 filter를 통해 권한 ID로 권한을 체크하는 로직을 만들어준다.
○ 권한이 없는 경우 403 forbidden을 내려준다.
API
메뉴 | 기능 | URI | Method |
유저관리 | 유저 리스트 출력 | /user | GET |
유저관리 | 유저 상세 | /user/{uid} | GET |
유저관리 | 유저 등록 | /user | POST |
유저관리 | 유저 수정 | /user/{uid} | PUT |
유저관리 | 유저 삭제 | /user/{uid} | DELETE |
유저관리 | 유저 주문 리스트 | /user/{uid}/order | GET |
유저관리 | 유저 주문 상세 | /user/{uid}/order/{orderId} | GET |
대충 구현할 API가 이렇게 있다고 가정을 해보자.
Database
테이블에 대해서 간략하게 설명해보자면
○ role : 권한 정보가 담겨 있는 테이블
○ menu : 메뉴 정보가 담겨 있는 테이블
○ scope : 메뉴별 scope 정보가 담겨 있는 테이블, menu 테이블의 id 값과 매핑하여 사용
○ scope_api : scope별 api 정보가 담겨 있는 테이블 (ex. read scope에 api_uri = /user, api_method = GET), scope 테이블의 id 값과 매핑
○ role_scope : 권한을 생성할 때 해당 권한에 추가한 scope 정보를 저장, unique key로 role_id, scope_id 사용
AccessDecisionManager, AcceessDecisionVoter에 대해서?
공식 문서에 아래와 같이 적혀있다.
Whilst users can implement their own AccessDecisionManager to control all aspects of authorization, Spring Security includes several AccessDecisionManager implementations that are based on voting. Using this approach, a series of AccessDecisionVoter implementations are polled on an authorization decision. The
AccessDecisionManager then decides whether or not to throw an AccessDeniedException based on its assessment of the votes.
?? 무슨 소리인지 모르겠다. 번역기 돌리자 (주섬주섬)
사용자는 승인의 모든 측면을 제어하기 위해 자신의 AccessDecisionManager를 구현할 수 있지만 Spring Security에는 여러 AccessDecisionManager가 포함되어 있습니다. 투표를 기반으로 하는 구현. 이 접근 방식을 사용하면 일련의 AccessDecisionVoter 구현이 승인 결정에 대해 폴링됩니다. 그런 다음 AccessDecisionManager는 AccessDeniedException 발생 여부를 결정합니다. 투표에 대한 평가를 기반으로 합니다.
음 그니까 대충 권한 제어를 위해 AccessDecisionManager라는 기능을 사용할 수 있고, 이 AcessDecisionManager를 사용하면 AccessDecisionVoter의 투표 구현에 따라 승인 결정을 내릴 수 있고, 이때 403 forbidden을 내려 줄 수 있다는 것 같다.
그래서 나는 JWT Token에 권한 ID 값을 부여해주고 filter에서 권한 ID를 통해 유저가 가진 권한 Scope을 알아내어 처리하는 방향으로 생각을 했다.
자 그럼 구현해보자!
구현
🛠 spring boot 2.5.7, Kotlin, gradle, MySQL 🛠
1. gradle에 Spring Security를 추가하자.
implementation("org.springframework.boot:spring-boot-starter-security")
2. Spring Security를 설정하자.
@EnableWebSecurity
@EnableGlobalMethodSecurity(
prePostEnabled = true
)
class SecurityConfig(
private val unauthorizedHandler: JwtAuthenticationEntryPoint,
private val userDetailsService: UserDetailsService,
private val jwtAuthenticationTokenFilter: JwtAuthenticationTokenFilter,
private val accessDecisionVoter: CustomAccessDecisionVoter,
private val accessDeniedHandler: CustomAccessDeniedHandler
): WebSecurityConfigurerAdapter() {
// ... 생략
@Bean
fun accessDecisionManager(): AccessDecisionManager {
val decisionVoter = listOf(
AuthenticatedVoter(),
RoleVoter(),
WebExpressionVoter(),
accessDecisionVoter
)
return UnanimousBased(decisionVoter)
}
override fun configure(http: HttpSecurity) {
http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(unauthorizedHandler)
.accessDeniedHandler(accessDeniedHandler)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.anyRequest()
.authenticated()
.accessDecisionManager(accessDecisionManager())
.and()
.cors()
http
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter::class.java)
}
override fun configure(web: WebSecurity) {
web
.ignoring()
.antMatchers(
"/api/v1/auth/**",
"/api/v1/menu", // 권한 상관 없이 메뉴는 불러와야하므 이렇게 설정했다.
"/",
)
}
}
○ AccessDecisionManager를 bean으로 설정한다.
○ accessDecisionVoter는 Custom 한 AccessDecisionVoter이다. 저 안에 권한 제어 로직이 담겨있다.
○ 공식문서에서는 투표를 처리하는 구현은 3가지가 있다고 한다.
○ AffirmativeBased : 투표자들 중 하나라도 찬성 투표를 반환하면 액세스 권한을 부여
○ ConsensusBased : 다수결로 처리 (기권은 무시한다.)
○ UnanimousBased : 모든 투표자들이 기권하거나 찬성 투표를 반환하면 액세스 권한을 부여 (내가 사용한 것)
○ configure의 accessDeniedHandler에는 AccessDeniedException이 발생했을 때 403 error를 Custom 한 내용이다.
3. AccessDecisionVoter를 어떻게 Custom 했는지 보자.
@Component
class CustomAccessDecisionVoter(
private val adminRoleScopeRepository: AdminRoleScopeRepository,
): AccessDecisionVoter<Any> {
override fun supports(
attribute: ConfigAttribute?
): Boolean {
return true
}
override fun supports(
clazz: Class<*>?
): Boolean {
return true
}
override fun vote(
authentication: Authentication,
`object`: Any,
attributes: MutableCollection<ConfigAttribute>
): Int {
val jwtAdminUser = authentication.principal as JwtAdminUser
val roleId = jwtAdminUser.getRoleId()
val scopeApiInfo = adminRoleScopeRepository.getScopeApiInfoByRoleId(roleId)
val request: HttpServletRequest = (`object` as FilterInvocation).request // FilterInvocation으로 cast 한 후 HttpServletRequest로 가져온다.
val pathPrefix = "/api/*" // /api/v1, /api/v2 등등 api가 업데이트 될 것을 고려하여 *을 통해 pattern 처리한다.
val pathMatcher = AntPathMatcher()
val isAuth = scopeApiInfo.stream()
.anyMatch { scopeApiInfo ->
request.method.equals(scopeApiInfo.apiMethod.name, ignoreCase = true) &&
pathMatcher.match(pathPrefix + scopeApiInfo.apiUri, request.requestURI)
}
// scopeApiInfo 리스트를 돌면서 request method가 일치하는지와 API의 URI가 일치하는지 확인한다.
return when (isAuth) {
true -> ACCESS_GRANTED
false -> ACCESS_DENIED
}
}
}
○ AccessDecisionVoter는 3가지 메서드가 있는데 실은 공식문서를 보아도 무슨 뜻인지 잘 모르겠다. 쨌든 공식 문서에서는 supports 메서드는 전부 true를 반환한다길래 나도 true를 반환했다.
○ supports(ConfigAttribute attribute) : 투표자가 특정 구성 속성을 지원하는지에 대한 여부
○ supports(Class clazz) : 투표자가 보안 개체 유형에 대해 투표할 수 있는지에 대한 여부
○ vote : 여기서 권한 제어 로직을 작성할 수 있다.
○ 순서대로 로직을 설명하자면
- SecurityContextHolder에 담아 놓은 유저 정보를 가져와 이 유저의 권한 ID 값을 가져온다.
- 그 권한 ID를 통해 role_scope 테이블에서 가지고 있는 권한의 API 리스트를 가져온다.
- AntPathMatcher의 match 메서드를 사용하여 /user/1/orders값과 /user/*/orders를 비교하면 동일한 Path라고 판단해 true를 return 해준다. (PathVariable 처리가 가능하다.)
- 권한에 대해서 return 해주는 값은 공식문서에 3개를 설명하고 있다.
- ACCESS_GRANTED : 허용
- ACCESS_DENIED : 거부
- ACCESS_ABSTAIN : 기권
AccessDeniedHandler는 뭐 사실 그냥 별 내용 없고 403 내려주는 별 의미 없는 코드라(ㅋㅋㅋ..) 딱히 작성하지는 않겠다.
마무리
권한 처리는 해봤어도 권한별 Scope 처리는 처음 해봐서 많이 고민을 했다. 비록 DB에서 API를 관리하면 API가 추가될 때 이 API에 대한 정보를 DB에 직접 입력을 해야 한다는 번거로움이 있다. 더 좋은 방법이 있다면 궁금하다!!!!!! 그냥 이게 내 최선ㅇ이였다..