개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › 보안 › 인가

메서드 보안

메서드에 붙인 권한 검사는 언제 실행되고 언제 빠지나

주소(URL)가 아니라 메서드에 "누가 이 메서드를 실행해도 되는가"를 적어 두는 방법이 메서드 보안입니다. @PreAuthorize는 메서드를 실행하기 전에, @PostAuthorize는 메서드가 값을 돌려준 뒤에 식을 계산해 거짓이면 거부합니다. 그런데 이 애너테이션은 붙여 두기만 해서는 동작하지 않을 때가 있습니다.

실행으로 대조 · 스프링 시큐리티 7.1.1에서 서른여섯 가지 조합을 실행해 검사 실행 여부, 본문 실행 여부, 받은 값이나 예외를 대조

이 실습에 나오는 개념

메서드 보안(@PreAuthorize, @PostAuthorize)
주소(URL)가 아니라 메서드에 권한 조건을 적어 두는 스프링 시큐리티의 기능이다. @PreAuthorize는 메서드를 실행하기 전에 식을 계산해 거짓이면 본문을 실행하지 않고, @PostAuthorize는 본문을 실행한 뒤 돌려줄 값을 보고 식을 계산해 거짓이면 값을 돌려주지 않는다. 식에는 hasRole('ADMIN') 같은 역할 확인뿐 아니라 메서드의 인자(#이름), 돌려줄 값(returnObject), 로그인한 사용자(authentication)를 쓸 수 있다. 설정 클래스에 @EnableMethodSecurity가 있어야 동작하며, 없으면 애너테이션은 오류 없이 무시된다. 빈을 감싼 프록시가 검사를 하므로 같은 클래스 안에서 부른 호출에는 적용되지 않는다.
인증과 인가
인증(Authentication)은 사용자가 누구인지 확인하는 것이고, 인가(Authorization)는 확인된 사용자가 특정 자원이나 기능을 사용할 권한이 있는지 판단하는 것이다. 인증이 먼저 이뤄지고 그 결과를 바탕으로 인가를 판단한다. HTTP 상태 코드로는 인증 실패가 401, 인가 실패가 403에 해당한다.
프록시(Proxy)
실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.
자기 호출(Self-invocation)
같은 객체 안에서 자신의 다른 메서드를 직접 호출하는 것이다. 프록시 기반의 기능은 외부에서 프록시를 거쳐 들어오는 호출에만 적용되므로, 내부에서 this로 호출한 메서드에는 @Transactional, @Async, @Cacheable 같은 설정이 적용되지 않는다. 해당 메서드를 다른 빈으로 분리하는 것이 일반적인 해결책이다.
SecurityContext
현재 요청을 보낸 사용자의 인증 정보(누구인지, 어떤 권한이 있는지)를 담아 두는 Spring Security의 저장소다. 인증에 성공하면 이곳에 정보가 저장되고, 이후 컨트롤러나 서비스에서 현재 사용자를 조회할 때 여기서 꺼낸다. 기본적으로 스레드별로 보관된다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 애플리케이션이 뜰 때: 설정 클래스에 @EnableMethodSecurity가 없습니다. 애너테이션은 메서드에 붙은 표시일 뿐이고, 그것을 읽어 검사해 줄 것이 없습니다. 프록시는 만들어지지 않고, 컨트롤러는 실제 객체를 그대로 주입받습니다. 오류나 경고는 나오지 않습니다.
  2. orderService.deleteAll(): 컨트롤러가 메서드를 부릅니다. 지금 로그인한 사용자는 kim(역할 USER)입니다. 프록시가 없으므로 호출이 실제 객체의 메서드로 바로 갑니다.
  3. deleteAll() 본문 실행: 주문을 모두 지우고 "모두 삭제함"을 돌려줍니다. 권한 검사를 거치지 않았으므로, 지금 로그인한 사용자가 실행해서는 안 되는 메서드가 그대로 실행됐습니다.
  4. 부른 쪽(컨트롤러): "모두 삭제함"을 받습니다. 거부됐어야 할 호출이 정상적으로 끝났습니다.

실행 결과

코드

@Configuration
// @EnableMethodSecurity가 없다
class SecurityConfig { }

@Service
class OrderService {
    @PreAuthorize("hasRole('ADMIN')")                 // 메서드를 실행하기 전에 확인
    public String deleteAll() {
        orders.clear();
        return "모두 삭제함";
    }
}

@RestController
class OrderController {
    private final OrderService orderService;       // 스프링이 넣어 준 빈
    OrderController(OrderService orderService) { this.orderService = orderService; }

    @GetMapping("/try")
    Object call() {                                // 지금 로그인한 사용자: kim (ROLE_USER)
        return orderService.deleteAll();
    }
}

이 모형의 전제

서른여섯 가지 조합 모두를 이 PC의 스프링 시큐리티 7.1.1(스프링 프레임워크 7.0.9, JDK 21)에서 화면과 같은 구조의 코드로 실행해 대조했습니다. 조합의 대조는 웹 서버 없이 스프링 컨텍스트에서 서비스 빈의 메서드를 직접 불러서 했으며, 로그인한 사용자는 SecurityContext에 직접 넣었습니다. 권한 검사가 실행됐는지는 검사할 때마다 불리는 AuthorizationEventPublisher 빈으로 세었고, 메서드 본문이 실행됐는지, 부른 쪽이 받은 값이나 예외의 종류, 빈이 프록시인지를 함께 확인했습니다. 거부될 때 던져지는 AuthorizationDeniedException이 AccessDeniedException의 한 종류라는 것도 실행으로 확인했습니다. 웹 요청에서 이 예외가 403 응답이 된다는 것은 스프링 부트 4.1.1 서버를 127.0.0.1에 띄워 따로 확인했습니다. 컨트롤러가 deleteAll()을 부르는 주소에 USER인 kim으로 요청하니 403이 나오고 본문은 실행되지 않았으며, ADMIN인 park으로 요청하니 200이 나왔습니다. 식에서 #owner처럼 인자의 이름을 쓰려면 컴파일할 때 -parameters 옵션이 필요합니다. 이 옵션 없이 컴파일해 실행했더니 #owner가 비어 있어 주인인 kim의 호출도 거부됐습니다(스프링 부트의 빌드 도구는 이 옵션을 기본으로 켠다고 문서에 나와 있으나 그 부분은 확인하지 않았습니다). 로그인 정보가 전혀 없는 상태에서 검사가 걸린 메서드를 부르면 AuthorizationDeniedException이 아니라 AuthenticationCredentialsNotFoundException이 나왔습니다. 화면의 본문 코드(orders.clear() 등)는 뜻을 보이려고 줄여 적은 것이며, 실행할 때는 본문이 실행된 횟수를 세었습니다. 스프링 부트가 이 설정을 대신 켜 주지는 않았습니다. 같은 스프링 부트 서버에서 @EnableMethodSecurity만 빼고 실행하니 USER인 kim의 요청에도 200이 나오고 본문이 실행됐습니다. 같은 클래스 안의 호출에서 검사가 빠지는 것은 프록시 방식의 일반적인 한계이고, "프록시와 자기 호출" 실습에서 자세히 다룹니다. @PreFilter, @PostFilter, @Secured, 직접 만든 빈을 식에서 부르는 방법은 다루지 않았습니다.

앞 실습: 접근 제어 · 다음 실습: 웹 공격 세 가지

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요