이거 모르면 Method Security 쓰지마세요.

유알·2024년 4월 12일
1

Method Security

Spring Security에서는 AOP를 활용하여 Method Security라는 기능을 제공한다.
요청당 인가 조건을 설정하던 기존 방식과 다르게, 메서드마다 특정 인자 조건을 설정하는게 장점이다.
예를 들면

@Service
public class MyCustomerService {
    @PreAuthorize("hasAuthority('permission:read')")
    @PostAuthorize("returnObject.owner == authentication.name")
    public Customer readCustomer(String id) { ... }
}

그렇다 메서드 수준에서 체크를 해줄 수 있다.

내가 생각했을 때 이게 유용한 이유는 다음과 같다.

  • 메서드 별로 접근 제어 가능
  • 소유권 기반(누가 뭐를 소유했는지 DB를 봐야하는 경우) 접근 제어
  • 리턴 값을 통해 인가를 결정해야 하는 경우
  • rest api, graphQl등 다양한 엔트리 포인트를 제공하는 경우

사실 언급하자면 끝도 없이 많다. 핵심은 기존 요청 기반 처리보다 더 세밀한 처리 가 가능하다는 점이다.

오늘은 이 내용을 말하려는 것은 아니고, 내가 했던 실수이자 다들 많이 하는 실수를 말해보고자 한다. 그리고 간단한 팁을 제공하고 글을 마치겠다.

AOP 순서를 고려해야 한다.

이게 이번 글의 핵심

이걸 모르면 아예 쓰면 안된다.

예를 들어 다음과 같은 로직이 있다고 해보자.(간이 코드다)

@Service
public class MyCustomerService {
    @Transactional
    @PreAuthorize("@accessChecker.isOwner(authentication.name, #id)")
    public Customer deleteCustomer(String id) { 
        // 메서드 내용
    }
}

자 이 상황에서 Transaction이 걸리고 우리의 인가 로직이 실행되는가? 아니면 그 반대인가?

Transactional이 먼저 붙어 있으니까 먼저 실행될까?

이게 왜 중요할까?
트랜잭션 밖에서 읽어 들인 값에 대해서 일관성을 보장해 주지 않는다.
더 정확히는 Isolation Level에 따라 다르지만, 트랜잭션 밖에서 읽어들인 값에 대해서는 완전히 관심 밖이다.
또 이런 경우를 생각해봐라, @PostAuthorize 를 통해 리턴된 인자를 검증했는데, 만약 권한이 없는 것으로 나와서 AccessDeniedExcpetion이 발생했다.
그러면 트랜잭션은 롤백이 되는가? 안되는가?
그렇다 이는 매우 중요한 문제다

Spring Order

  • 간단하게 설명하면 Spring에서는 Bean의 Order를 설정할 수 있다.
  • 낮을 수록 더 우선순위가 높고(AOP가 먼저 실행되고), 높을 수록 우선순위가 낮다(AOP가 더 늦게 실행된다)
  • 범위는 Integer.MIN_VALUE ~ Integer.MAX_VALUE까지다.
  • 만약 명시적으로 설정하지 않으면 가장 우선순위가 낮은 Integer.MAX_VALUE가 설정된다.

Transactional 하고 Method Security 모두 AOP 를 사용한다.

둘다 AOP를 활용해서 작동을 한다. 따라서 이 AOP들의 실행 순서를 인지하는 것은 매우 중요하다.

일단 Method Security 동작 구조와 순서를 보자.

이는 공식 문서에서 가져온 그림이다. (https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html#method-security-architecture)

중요한 사실을 알 수 있는데.

  • 필터를 모두 통과하고 나서 호출된다.
  • 메서드 호출 앞에 AOP 가 가로체서 인가 여부를 따진다.
  • 뒤쪽에 AOP가 따로 있다.
  • 실패하면, 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;
    }
}

그렇다 요약하자면

  • preFilter : 100
  • PreAuthorized : 200

이런식으로 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

이런 순서로 호출된다.

정리하면 기본 설정 그대로 사용한다면, 다음과같다.

  • pre 쪽에서 호출되는 코드는 트랜잭션 밖이다.
  • post 쪽에서 만약 예외가 발생해도 트랜잭션은 롤백되지 않는다.

이 말이된다.

그렇다면 순서는 어떻게 조정해?

공식문서에서는 다음과 같이 설정해서 기본 Order를 조정하는 방식을 말하고 있다.

@EnableTransactionManagement(order = 0)

stackOverflow에서는 AOP에 @Transaction과 Propagation 속성을 사용하라는 의견도 있는 것 같다.

어쨋든 무조건 이렇게 설정해야하는 것이 아니라 자신의 메서드 시큐리티 AOP가 트랜잭션 안에 들어가야하는지 고민해봐야한다.

Controller Advice 사용에 주의하라

이건 또 무슨 말이냐
원래 Spring Security를 사용할 때는, 인가가 어떻게 작동하는가?

  • AuthorizationFilter에서 AccessDeniedException을 던지면
  • ExceptionTranslactionFilter에서 이를 catch해서 적절한 핸들러로 던지지 않는가?

근데 맨 위 그림을 다시 보면 알겠지만, Method Security는 Filter 쪽에서 작동안할 수 있다.(Filter에서 작동하는지는, 확인 안해봤다. 근데 아마 될듯 Delegator Filter로 스프링 빈이니까)
따라서 만약 ControllerAdvice에서 Exception을 다 잡아 버리면? AccessDeniedException이 우리가 사전에 설정해 놓은 AccessDeniedHandler까지 안갈 수 있다.

또 다른 팁 : RoleHierarchy

또 다른 팁으로는 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;
    }

잘못된 내용 있으면 댓글로 알려주세요~~

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글