본문 바로가기

스프링

[Spring Security] @PreAuthorize

agent skills를 찾아보다가 Spring Security에 대한 Skills를 읽어보게 되었는데 처음 보는 구문이 나와 찾아보게되었다.

  @PreAuthorize("hasRole('ADMIN')")
  @GetMapping("/users")
  public List<UserDto> listUsers() {
    return userService.findAll();
  }

  @PreAuthorize("@authz.isOwner(#id, authentication)")
  @DeleteMapping("/users/{id}")
  public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();
  }

바로 @PreAuthorize 애노테이션이다.

Method-Level 권한 모델링

다음과 같이 Security Config에서 request-level에서 권한을 모델링하는 방법과 더불어 메서드 수준에서 인가(Authorization) 설정하게 해주는 방법이다.

// request-level
http
    .authorizeHttpRequests((authorize) -> authorize
        .anyRequest().authenticated()
    )

애노테이션 기반의 메서드 보안을 적용한다면, 애노테이션이 없는 메서드는 보호되지 않는다. 즉, method-level 애노테이션만을 사용하는 것이 아니라, 반드시 HttpSecurity를 통해 기본적인 방어는 필요하다.

동작 방식

Method Security는 Spring AOP 사용하여 구현된다. 다음과 같은 메서드가 있다고 하자.

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

스프링은 실제 구현체가 아닌 프록시를 호출한다. 따라서 readCustomer가 호출되면 다음과 같은 과정을 거친다.

  1. 프록시인 myCustomService가 호출된다.
  2. 프록시는 여러 interceptor 중 @PreAuthorize에 해당하는 인터셉터인 AuthorizationManagerBeforeMethodInterceptor 를 찾아 실행한다.
  3. 2에서 찾은 AuthorizationManagerBeforeMethodInterceptorPreAuthorizeAuthorizationManager에게 인가 판단을 맡긴다.
  4. 가장 중요한 부분이다. @PreAuthorize의 ()안의 문자열을 SpEL 표현식으로 해석한다.
    이 작업을 MethodSecurityExpressionHandler가 표현식을 파싱하고, EvaluationContextMethodSecurityExpressionRoot를 만든다. 이 context 안에는 현재 메서드 정보, 파라미터, Authentication등의 정보가 들어간다.
  5. 이제 실제 표현식의 평가가 일어나고, 통과한다면 실제 메서드가 실행된다.
  6. 실패한다면, 메서드는 실행되지 않고 AccessDeniedException이 발생한다.
    이때, Exception 외에 AuthorizationDeniedEvent 가 발행되어 로깅, 모니터링, 감사 목적으로도 활용이 가능하다고 한다. AccessDeniedException 은 시큐리티 필터체인의 ExceptionTranslationFilter가 잡아서 403을 반환한다. 만약, HTTP 요청이 아니고, 스케줄런 배치 등의 상황에서 이 예외가 발생한다면, 직접 처리해야한다.(If the method is not being called in the context of an HTTP request, you will likely need to handle the AccessDeniedException yourself)

사용법

아무 @Configuration 클래스에 @EnableMethodSecurity를 추가해주면 애노테이션을 사용할 수 있다.

프록시를 기반으로 구현되기에 Bean의 메서드에서만 해당 애노테이션이 사용 가능하다.(컨트롤러/서비스 메서드가 실제로 실행되기 전에 가로채서 표현식을 평가하기에)

역할/권한 검사

@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
@PreAuthorize("hasAuthority('user:delete')")
@PreAuthorize("hasAnyAuthority('user:read', 'user:write')")

인증 여부 검사

@PreAuthorize("isAuthenticated()")
@PreAuthorize("isAnonymous()")
@PreAuthorize("isFullyAuthenticated()")

파라미터 기반 검사

@PreAuthorize("#id == authentication.principal.id")
public void deleteUser(Long id) { ... }

커스텀 Bean 호출

@PreAuthorize("@authz.isOwner(#id, authentication)")
...

@Component("authz")
public class Authz {
    public boolean isOwner(Long id, Authentication authentication) {
        Long currentUserId = ((CustomPrincipal) authentication.getPrincipal()).getId();
        return currentUserId.equals(id);
    }
}

이때 @authz는 Bean name이고, isOwner는 직접 구현해야한다.

주의

  • 같은 메서드에 동일한 애노테이션을 여러 번 사용할 수 없다.
// X
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAuthority('user:delete')")

// O
@PreAuthorize("hasRole('ADMIN') and hasAuthority('user:delete')")

관련된 애노테이션

  • @PostAuthorize
    메서드 실행 반환값까지 보고 인가
  • @PreFilter
    메서드 실행 컬렉션 인자 필터링
  • @PostFilter
    메서드 실행 컬렉션 반환값 필터링
  • @Secured
  • JSR-250 계열 (@RolesAllowed 등)

Spring Security는각각에 대해 별도의 인터셉터를 가진다.

장점

  • URL 기반(request-level) 제어의 한계를 보완하고, 리소스/도메인 기반 권한(ownership, 조직, 상태 등)을 표현할 수 있다.
  • 권한 확인 로직으로 서비스 코드가 길어지는 점을 개선시킬 수 있다는 장점이 있다.
  • 동일 권한 로직을 Bean으로 추출해 재사용 및 테스트 용이성이 좋다.

단점

  • 조건이 복잡해질 경우, SpEL의 가독성/유지보수성이 낮아진다. 이 경우 서비스 로직에서 처리하는 것이 좋다.
  • 내부에서 DB를 호출한다면, 요청마다 비용이 발생한다. 이에 대한 비용은 개발자의 책임이다.
  • AOP 기반 제약 존재 → 동일 클래스 내부 호출(self-invocation)에서는 적용되지 않을 수 있다.

 

https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html

 

Method Security :: Spring Security

There are some scenarios where you may not wish to throw an AuthorizationDeniedException when a method is invoked without the required permissions. Instead, you might wish to return a post-processed result, like a masked result, or a default value in cases

docs.spring.io