Spring Security에서는 AOP를 활용하여 Method Security
라는 기능을 제공한다.
요청당 인가 조건을 설정하던 기존 방식과 다르게, 메서드마다 특정 인자 조건을 설정하는게 장점이다.
예를 들면
@Service
public class MyCustomerService {
@PreAuthorize("hasAuthority('permission:read')")
@PostAuthorize("returnObject.owner == authentication.name")
public Customer readCustomer(String id) { ... }
}
그렇다 메서드 수준에서 체크를 해줄 수 있다.
내가 생각했을 때 이게 유용한 이유는 다음과 같다.
사실 언급하자면 끝도 없이 많다. 핵심은 기존 요청 기반 처리보다 더 세밀한 처리
가 가능하다는 점이다.
오늘은 이 내용을 말하려는 것은 아니고, 내가 했던 실수이자 다들 많이 하는 실수를 말해보고자 한다. 그리고 간단한 팁을 제공하고 글을 마치겠다.
이게 이번 글의 핵심
이걸 모르면 아예 쓰면 안된다.
예를 들어 다음과 같은 로직이 있다고 해보자.(간이 코드다)
@Service
public class MyCustomerService {
@Transactional
@PreAuthorize("@accessChecker.isOwner(authentication.name, #id)")
public Customer deleteCustomer(String id) {
// 메서드 내용
}
}
자 이 상황에서 Transaction이 걸리고 우리의 인가 로직이 실행되는가? 아니면 그 반대인가?
Transactional이 먼저 붙어 있으니까 먼저 실행될까?
이게 왜 중요할까?
트랜잭션 밖에서 읽어 들인 값에 대해서 일관성을 보장해 주지 않는다.
더 정확히는 Isolation Level에 따라 다르지만, 트랜잭션 밖에서 읽어들인 값에 대해서는 완전히 관심 밖이다.
또 이런 경우를 생각해봐라, @PostAuthorize 를 통해 리턴된 인자를 검증했는데, 만약 권한이 없는 것으로 나와서 AccessDeniedExcpetion이 발생했다.
그러면 트랜잭션은 롤백이 되는가? 안되는가?
그렇다 이는 매우 중요한 문제다
둘다 AOP를 활용해서 작동을 한다. 따라서 이 AOP들의 실행 순서를 인지하는 것은 매우 중요하다.
일단 Method Security 동작 구조와 순서를 보자.
이는 공식 문서에서 가져온 그림이다. (https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html#method-security-architecture)
중요한 사실을 알 수 있는데.
AccessDeniedException
이 발생한다.이들은
org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor
org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor
이렇게 두 곳에서 코드를 보면 보이는데, 이곳에 보면 static 생성 메서드가 있는 것을 볼 수 있다.
public static AuthorizationManagerBeforeMethodInterceptor preAuthorize(PreAuthorizeAuthorizationManager authorizationManager) {
AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor(AuthorizationMethodPointcuts.forAnnotations(new Class[]{PreAuthorize.class}), authorizationManager);
interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder());
return interceptor;
}
오 순서가 보이는가?
package org.springframework.security.authorization.method;
public enum AuthorizationInterceptorsOrder {
FIRST(Integer.MIN_VALUE),
PRE_FILTER,
PRE_AUTHORIZE,
SECURED,
JSR250,
POST_AUTHORIZE,
POST_FILTER,
LAST(Integer.MAX_VALUE);
private static final int INTERVAL = 100;
private final int order;
private AuthorizationInterceptorsOrder() {
this.order = this.ordinal() * 100;
}
private AuthorizationInterceptorsOrder(int order) {
this.order = order;
}
public int getOrder() {
return this.order;
}
}
그렇다 요약하자면
이런식으로 100씩 증가하면서 순서를 부여한다.
그렇다면 우리가 사용하는 Transactional 은 순서가 뭘까?
The reason this is important to note is that there are other AOP-based annotations like @EnableTransactionManagement that have an order of Integer.MAX_VALUE. In other words, they are located at the end of the advisor chain by default.
(spring security doc)
EnableTransactionManagement를 통해 등록되는 @Transactional 에 반응하는 AOP는 Integer.MAX_VALUE(기본값)으로 설정된다.
이는 다른 말로하면, AOP 체인의 맨 마지막에 위치한다는 것이다.
즉 기본 설정을 그대로 사용하면,
1. pre
2. transactional
3. 메서드 호출
4. transactional
5. post
이런 순서로 호출된다.
정리하면 기본 설정 그대로 사용한다면, 다음과같다.
이 말이된다.
공식문서에서는 다음과 같이 설정해서 기본 Order를 조정하는 방식을 말하고 있다.
@EnableTransactionManagement(order = 0)
stackOverflow에서는 AOP에 @Transaction
과 Propagation 속성을 사용하라는 의견도 있는 것 같다.
어쨋든 무조건 이렇게 설정해야하는 것이 아니라 자신의 메서드 시큐리티 AOP가 트랜잭션 안에 들어가야하는지 고민해봐야한다.
이건 또 무슨 말이냐
원래 Spring Security를 사용할 때는, 인가가 어떻게 작동하는가?
AuthorizationFilter
에서 AccessDeniedException을 던지면ExceptionTranslactionFilter
에서 이를 catch해서 적절한 핸들러로 던지지 않는가?근데 맨 위 그림을 다시 보면 알겠지만, Method Security는 Filter 쪽에서 작동안할 수 있다.(Filter에서 작동하는지는, 확인 안해봤다. 근데 아마 될듯 Delegator Filter로 스프링 빈이니까)
따라서 만약 ControllerAdvice에서 Exception을 다 잡아 버리면? AccessDeniedException이 우리가 사전에 설정해 놓은 AccessDeniedHandler까지 안갈 수 있다.
또 다른 팁으로는 RoleHierarchy를 사용하라는 것이다.
이 내용에 깊게 다루는 것은 글의 요지를 벗어나므로, 설명하진 않겠다.
이 빈을 통해서 우리는 각 Authority를 계층적으로 설계할 수 있다.
나의 경우는 각 Role을 계층적으로 설정하고, 그 Role마다 포함된 permission 같은것을 설정해서 Method Security에서는 이 권한만 체크하도록 코드를 작성했다.
이렇게 하면 추후에 특정 역할에 대해 권한을 지워야할 때, 로직 코드의 수정 없이 손쉽게 바꿀 수 있다.
@Bean
public RoleHierarchy roleHierarchy() {
//{domain}:{action}:{scope}
//domain : place, plan
//action : read, create, update, delete
//scope : owned, belonged, all, new(only for *:create)
String hierarchyString = """
ROLE_ADMIN > ROLE_STAFF
ROLE_STAFF > ROLE_USER
ROLE_STAFF > place:read:all
ROLE_STAFF > plan:read:all
ROLE_USER > place:read:all
ROLE_USER > plan:create:owned
ROLE_USER > plan:read:owned
ROLE_USER > plan:read:belonged
ROLE_USER > plan:update:owned
ROLE_USER > plan:update:belonged
ROLE_USER > plan:delete:owned
""";
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy(hierarchyString);
return hierarchy;
}
//for method security
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy);
return expressionHandler;
}
잘못된 내용 있으면 댓글로 알려주세요~~