개발 학습 실습실

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

공부 › 보안 › 인가

접근 제어

로그인한 사용자가 주소의 번호만 바꾸면 남의 주문이 보이나

로그인했다는 것은 "누구인지 확인됐다"(인증)는 뜻일 뿐, "이 자원을 봐도 된다"(인가)는 뜻이 아닙니다. kim이 로그인한 뒤 주소의 주문 번호만 바꿔 lee의 주문을 요청하면 어떻게 될까요. 서버가 무엇을 확인하느냐에 따라 그대로 보이기도 하고 거절되기도 합니다.

실행으로 대조 · 스프링 부트 4.1.1과 스프링 시큐리티 7.1.1 서버를 127.0.0.1에 띄워 서른 가지 조합의 응답 코드와 본문을 대조

이 실습에 나오는 개념

인증과 인가
인증(Authentication)은 사용자가 누구인지 확인하는 것이고, 인가(Authorization)는 확인된 사용자가 특정 자원이나 기능을 사용할 권한이 있는지 판단하는 것이다. 인증이 먼저 이뤄지고 그 결과를 바탕으로 인가를 판단한다. HTTP 상태 코드로는 인증 실패가 401, 인가 실패가 403에 해당한다.
접근 제어와 소유자 확인
접근 제어는 확인된 사용자가 어떤 자원을 보거나 바꿔도 되는지를 서버가 정하고 지키게 하는 일이다. 로그인했는지만 확인하고 자원의 주인을 확인하지 않으면, 주소나 요청에 든 번호만 바꿔 다른 사람의 자원을 볼 수 있다. 이런 취약점을 IDOR(안전하지 않은 직접 객체 참조)라고 부른다. 막으려면 자원을 조회한 뒤 그 자원의 주인과 지금 로그인한 사용자를 서버 코드에서 비교하거나, 처음부터 "내 것 가운데에서만" 조회한다. 화면에서 버튼이나 링크를 숨기는 것은 접근 제어가 아니며, 확인은 반드시 서버에서 요청마다 한다.
역할 기반 접근 제어(RBAC)
사용자에게 USER, ADMIN 같은 역할을 주고, 역할마다 할 수 있는 일을 정해 두는 방식이다. 스프링 시큐리티에서는 hasRole("ADMIN")처럼 주소나 메서드에 필요한 역할을 적는다. 규칙이 단순하고 한곳에서 관리하기 쉽지만, 역할은 "어떤 종류의 사용자인가"만 말해 주므로 같은 역할을 가진 두 사용자를 구별하지 못한다. 그래서 "자기 주문만 볼 수 있다" 같은 규칙은 역할만으로는 표현할 수 없고, 자원의 주인을 비교하는 확인을 함께 써야 한다.
HTTP 상태 코드
서버가 요청을 처리한 결과를 나타내는 세 자리 숫자다. 2xx는 성공, 3xx는 다른 위치로의 이동(리다이렉션), 4xx는 클라이언트의 요청에 문제가 있음, 5xx는 서버 쪽 오류를 뜻한다. 200(성공), 201(생성됨), 400(잘못된 요청), 401(인증 필요), 403(권한 없음), 404(없음), 500(서버 오류)이 자주 쓰인다.
Spring Security와 SecurityFilterChain
Spring Security는 인증과 인가를 처리하는 스프링의 보안 프레임워크로, 서블릿 필터들의 묶음으로 동작한다. SecurityFilterChain은 어떤 요청에 어떤 보안 필터들을 어떤 순서로 적용할지 정의한 것이다. 요청이 컨트롤러에 도달하기 전에 필터들이 차례로 인증 정보 확인, 권한 검사 등을 수행한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. kim이 로그인한 상태로 GET /orders/2 요청을 보냅니다. kim의 주문은 1번이고 2번은 lee의 주문입니다. 주소의 번호만 2로 바꿔 본 것입니다.
  2. 인증: 서버가 요청에 실린 로그인 정보를 확인해, 보낸 사람이 kim이고 역할이 USER임을 알아냅니다. 여기까지는 "누구인가"만 확인한 것입니다. 2번 주문을 봐도 되는지는 아직 아무도 따지지 않았습니다.
  3. .anyRequest().authenticated(): URL 규칙은 로그인한 사용자인지만 봅니다. kim은 로그인했으므로 통과합니다. 이 규칙은 주소 끝의 2번이 누구의 주문인지 알지 못합니다.
  4. orders.findById(2): 컨트롤러가 번호로 주문을 조회합니다. lee의 주문이 나옵니다.
  5. return o: 조회한 주문을 그대로 돌려줍니다. 주문의 주인(lee)과 로그인한 사용자(kim)를 비교하는 코드가 어디에도 없습니다.
  6. 응답은 200 OK이고, 본문에 lee의 주문(상품과 배송지 주소)이 담겨 kim에게 갑니다. 로그인은 정상적으로 했고 서버의 모든 규칙을 통과했는데, 남의 주문이 보였습니다.

실행 결과

코드

// 주문 1번의 주인은 kim, 2번의 주인은 lee다. 99번 주문은 없다.
// kim과 lee의 역할은 USER, park의 역할은 ADMIN이다.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(a -> a
            .anyRequest().authenticated())                 // 로그인했는지만 본다
        .httpBasic(Customizer.withDefaults())              // 로그인 방법 (무엇이든 결과는 같다)
        .build();
}

@GetMapping("/orders/{id}")
Order one(@PathVariable long id, Authentication auth) {     // auth: 지금 로그인한 사용자
    Order o = orders.findById(id)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    return o;                                 // 누구의 주문인지 확인하지 않는다
}

이 모형의 전제

서른 가지 조합 모두를 이 PC에서 스프링 부트 4.1.1과 스프링 시큐리티 7.1.1 서버를 127.0.0.1에 띄우고 화면과 같은 구조의 코드로 실행해, 응답 코드와 본문에 담긴 주문의 주인, 컨트롤러가 실행됐는지, 코드의 어느 갈래에서 판단이 났는지를 대조했습니다. 주문 저장소는 데이터베이스 대신 메모리의 표로 만들었고, 로그인 방법은 HTTP Basic을 썼습니다(세션이나 토큰으로 로그인해도 인가의 결과는 같습니다). 로그인하지 않은 요청은 어느 방식에서나 401이 나오는 것을 같은 서버에서 확인했으며, 그 내용은 "시큐리티 필터 체인" 실습에서 다룹니다. 남의 자원에 403 대신 404로 답해 자원이 있다는 사실을 숨길 수 있다는 것은 RFC 9110 15.5.4절(403)과 15.5.5절(404)에 적힌 내용입니다. 403과 404 가운데 무엇이 옳은지는 정해져 있지 않고 서비스가 고르는 것입니다. 실제 서비스에서는 응답 코드 외에 응답 시간의 차이로도 자원이 있는지 드러날 수 있는데, 이 실습에서는 다루지 않았습니다. 소유자 확인을 컨트롤러에 적었지만 서비스 계층에 두거나 메서드 보안(@PreAuthorize, @PostAuthorize)으로 적기도 하며, 그 내용은 "메서드 보안" 실습에서 이어집니다. 주문 번호를 추측하기 어려운 값(UUID 등)으로 바꾸는 것은 번호를 짐작하기 어렵게 할 뿐 소유자 확인을 대신하지 못합니다. 이 문장은 일반적인 보안 권고(OWASP의 Broken Access Control 설명)를 따른 것이며 실행으로 확인한 내용이 아닙니다.

앞 실습: 시큐리티 필터 체인 · 다음 실습: 메서드 보안

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요