Spring, IP Filtering 어렵지 않게 제작하기

2022. 5. 1. 23:19Spring

Spring의 Interceptor를 통해 White Cidr(White IP List)만 접근할 수 있도록 하는 것이 해당 포스팅의 목표입니다.

 

 

백앤드를 제작하다보면 허용된 IP만 접근할 수 있게 해야할 때가 발생합니다.

이번 포스팅은 미리 지정된 White Cidr 범위 내의 IP만을 허용하는 Spring의 Interceptor를 제작하고자 합니다.

 

Interceptor는 이전 포스팅인 "Spring Interceptor, 제대로 이해하기"를 통해 참고하시길 바랍니다.

Cidr는 이전 포스팅인 "CIDR, 어렵지 않게 이해하기"를 통해 참고하시길 바랍니다.

 

 

Big Picture

해당 포스팅의 전반적인 내용은, 데이터베이스로 White Cidr 리스트를 저장하여 IP가 포함되는지를 체크하는 것입니다.

아래에서 언급하겠지만, 아래와 같이 cidr와 활성여부, 그리고 등록 일자를 갖는 white_cidr 테이블을 제작했습니다.

이 테이블에서 cidr 리스트를 가져온 후, 요청하는 IP가 포함되는지의 여부로 접근을 제어하는 기능을 제작합니다.

 

해당 기능을 제작하기 위해서는 아래와 같은 기능을 구현합니다.

 

- Util : 요청 IP를 가져옴

- DAO : 데이터베이스에서 white cidr 리스트를 가져옴

- Service : 요청 IP가 해당 리스트 내 포함되는 cidr가 존재하는지 체크

- Interceptor: 사용자의 요청을 가로채서 접근가능한 IP 인지 확인

 

추가로 Interceptor를 등록하는 코드등이 필요하며, 본 내용을 구현하는 것이 해당 포스팅의 내용입니다.

 

 

Util : getClientIp

 

먼저, 요청하는 Client의 IP를 알아야 겠죠?

 

public class IpUtil {

    private static final String[] IP_HEADER_CANDIDATES = {
        "Proxy-Client-IP",
        "WL-Proxy-Client-IP",
        "HTTP_X_FORWARDED_FOR",
        "HTTP_X_FORWARDED",
        "HTTP_X_CLUSTER_CLIENT_IP",
        "HTTP_CLIENT_IP",
        "HTTP_FORWARDED_FOR",
        "HTTP_FORWARDED",
        "HTTP_VIA",
        "REMOTE_ADDR"
    };
    
    public static String getClientIp(HttpServletRequest request) {
        String ip = getIpXFF(request);

        for (String ipHeader: List.of(IP_HEADER_CANDIDATES)) {
            ip = request.getHeader(ipHeader);
            if (!notValidIp(ip)) return ip;
        }

        return ip != null ? ip : request.getRemoteAddr();
    }

    private static boolean notValidIp(String ip) {
        return ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip);
    }
    
    // ...
}

 

Request에서 직접 IP를 가져옵니다. 

간단히 request.getRemoteAddr()를 할 수도 있겠지만 Proxy, Caching server, Load Balancer 등을 거쳐올 경우 getRemoteAddr() 를 이용하여 IP 주소를 가지고 오지 못하기 때문에 Header에서 직접 가져와야 합니다.

때문에, 위와 같은 코드를 작성하게 된 것이죠.

 

위의 getIpXFF의 코드는 아래와 같이 작성했습니다.

 

public class IpUtil {

	// ...
    
    @Nullable
    private static String getIpXFF(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");

        if (isMultipleIpXFF(ip)) {
            ip = getClientIpWhenMultipleIpXFF(ip);
        }

        return ip;
    }

    private static boolean isMultipleIpXFF(String ip) {
        // return ip.contains(",");
        return StringUtils.contains(ip, ",");  // null-safe
    }

    private static String getClientIpWhenMultipleIpXFF(String ipList) {
        // return ips.split(",")[0];
        return StringUtils.split(ipList, ",");   // null-safe
    }
}

 

위에서 언급했듯이, XFF(X-Forwarded-For)에서 받아온 IP가 

X-Forwarded-For: <client>, <proxy1>, <proxy2> ..  형태일 경우

 

규칙 상 가장 앞의 IP가 Client IP가 되기 때문에 가장 앞 단의 IP만을 사용합니다.

 

 

StringUtils

StringUtils는 org.apache.commons.lang3.StringUtils 패키지로 null-safe하게 String 값을 다룰 수 있습니다.

만약, 기존의 String.contains() 이나 split()을 사용하려면 따로 null 체크를 해주세요.

 

다음으로는 데이터베이스에서 white cidr리스트를 가져오는 DAO를 제작해보도록 하겠습니다.

 

 

 

DAO

✔️  SQL

먼저, DB Table은 아래와 같이 정의했습니다.

 

active와 reg_at 필드는 적용되는 cidr 관리를 용이하게 만들기 위해 추가한 것으로, 필수는 아닙니다.

DAO에서 연결되는 Query는 아래와 같습니다.

 

SELECT cidr FROM white_cidr WHERE active = 1;

 

위와 같이 적용되는 쿼리를 실행하는 DAO를 작성해보도록 할게요.

 

@Repository
public class WhiteCidrDAO {

    @Autowired
    private SqlSession sqlSession;

    public List<String> selectWhiteCidrList() {
        return sqlSession.selectList("selectWhiteCidrList");
    }
}

 

위와 같이 List<String> 형태를 반환하는 DAO를 간단하게 작성했습니다.

 

 

 

Service

 

가져온 Cidr List를 Client IP와 매칭하는 코드를 구현합니다.

 

@Service
@RequiredArgsConstructor
public class AllowCidrCheckServiceImpl implements AllowCidrCheckService {

    private final WhiteCidrDAO whiteCidrDAO;

    /**
     * client IP를 받아서
     * 등록된 white CIDR 리스트에 포함되는지 체크
     * 
     * @param clientIp
     * @return boolean  - white IP (True), NOT white IP (False)
     */
    public boolean isWhiteIp(String clientIp) {

        return whiteCidrDAO.selectWhiteCidrList()
            .stream()
            .anyMatch(cidr ->
                (new IpAddressMatcher(cidr)).matches(clientIp)
            );
    }
}

 

간단히 IpAddressMatcher 를 사용해서 구현하였습니다.

 

 

IpAddressMatcher

IpAddressMatcher는 Spring Security에서 제공하는 IP 매칭 여부를 판단하는 객체입니다.

공식문서를 확인해보면 아래와 같은 소개글을 확인할 수 있는데요.

 

Matches a request based on IP Address or subnet mask matching against the remote address. Both IPv6 and IPv4 addresses are supported, but a matcher which is configured with an IPv4 address will never match a request which returns an IPv6 address, and vice-versa.

 

간단히 해석해보면 아래와 같습니다.

 

원격 주소를 IP 주소나 서브넷 마스크를 기반으로 매칭합니다. IPv6 및 IPv4 주소를 모두 지원하지만, IPv4 주소로 구성된 매처는 IPv6 주소를 반환하는 요청과 매칭되지 않으며, 그 반대의 경우도 마찬가지입니다. (서로 호환되지 않습니다.)

 

 

사용은 매칭할 리스트나 주소를 가지고 객체를 생성한 후,

matches 메소드에 매칭될 원격 주소를 인자로 넣어 매칭 여부를 판단합니다.

 

IpAddressMatcher의 생성자 javadoc을 참고하면 사용에 조금 더 도움이 될 것 같아서 추가해봤습니다.

 

/** 
  * Takes a specific IP address or 
  *   a range specified using the IP/Netmask 
  * (e.g. 192.168.1.0/24 or 202.24.0.0/14).
  */
public IpAddressMatcher(String ipAddress);

 

이제, 생성한 Service를 Interceptor에 적용해보도록 하겠습니다.

 

 

 

Interceptor

Interceptor의 이해는 "Spring Interceptor, 제대로 이해하기"를 통해 참고하시길 바랍니다.

 

@Slf4j
@Component
@RequiredArgsConstructor
public class IpAccessControlInterceptor implements HandlerInterceptor {

    private final AllowCidrCheckService allowCidrCheckService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        String requestIp = getClientIp(request);

        if (!allowCidrCheckService.isWhiteIp(requestIp)) {
            String requestURI = request.getRequestURI();

            log.warn("Forbidden access. request uri={}, client ip={}", requestURI, requestIp);
            
            // redirect NOT AUTH PAGE or FORBIDDEN status
            response.sendError(403, "IP Forbidden");
            return false;
        }

        return true;
    }
}

 

Service 코드를 호출해서 white IP 가 아니라면, 접근할 수 없게끔 Interceptor를 설정합니다.

이번엔 제작한 Interceptor를 실제로 등록하는 코드를 추가하고 설정을 마무리 하겠습니다.

 

 

 

WebMvcConfig

 

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final IpAccessControlInterceptor ipAccessControlInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(ipAccessControlInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns("/resources/**")
            .excludePathPatterns("/error/**");
    }
}

 

WebMvcConfigurer를 구현한 클래스에 Interceptor를 추가해줍니다.

PathPattern은 상황에 맞게 등록해주시면 됩니다.

 

 

Test

이제, 제대로 작동하는지 확인해봐야겠죠.

필자는 Junit과 Postman을 통해 테스트를 해보았습니다.

 

Junit 5

먼저, Junit 5로 작성한 테스트 코드입니다.

 

@Slf4j
@SpringBootTest
class WhiteCidrAccessTest {

    @Autowired
    protected AllowCidrCheckService allowCidrCheckService;

    static private MockHttpServletRequest request;

    @BeforeAll
    static void setUp() {
        request = new MockHttpServletRequest();
    }
    
    // ...

}

 

기본적인 세팅은 위와 같습니다.

 

 

✔️ White IP를 입력했을 경우

첫 번째 테스트 코드는 허용된 Ip를 입력했을 때입니다.

 

@Slf4j
@SpringBootTest
class WhiteCidrAccessTest {

    @Autowired
    protected AllowCidrCheckService allowCidrCheckService;

    static private MockHttpServletRequest request;

    @BeforeAll
    static void setUp() {
        request = new MockHttpServletRequest();
    }

    @Test
    void given_ValidIP__when_WhiteCidrCheck__then_True() {

        // GIVEN
        String CLIENT_IP = "192.168.0.1";

        request.setMethod("get");
        request.setRequestURI("/test");
        request.setRemoteAddr(CLIENT_IP);

        String ip = getClientIp(request);
        log.info("ip : {}", ip);

        // WHEN
        boolean isWhiteIp = allowCidrCheckService.isWhiteIp(ip);

        // THEN
        log.info("isWhiteIp : {}", isWhiteIp);
        Assertions.assertTrue(isWhiteIp);
    }    
    /* result log
        ip : 192.168.0.1
        isWhiteIp : true
    */
}

 

결론적으로, 첫 번째 테스트인 given_ValidIP__when_WhiteCidrCheck__then_True 메소드는

DB에 저장된 192.168.0.1/32 에 해당되어 white Ip로 처리됩니다.

 

 

 

✔️ White IP가 아닌 IP를 입력했을 경우

두 번째 테스트 코드는 허용되지 않는 Ip를 입력했을 때입니다.

 

@Slf4j
@SpringBootTest
class WhiteCidrAccessTest {

    @Autowired
    protected AllowCidrCheckService allowCidrCheckService;

    static private MockHttpServletRequest request;

    @BeforeAll
    static void setUp() {
        request = new MockHttpServletRequest();
    }
    
    @Test
    void given_InvalidIP__when_WhiteCidrCheck__then_False() {

        // GIVEN
        String CLIENT_IP = "192.168.121.0";

        request.setMethod("get");
        request.setRequestURI("/test");
        request.setRemoteAddr(CLIENT_IP);

        String ip = getClientIp(request);
        log.info("ip : {}", ip);

        // WHEN
        boolean isWhiteIp = allowCidrCheckService.isWhiteIp(ip);

        // THEN
        log.info("isWhiteIp : {}", isWhiteIp);
        Assertions.assertFalse(isWhiteIp);
    }
    /* result log
        ip : 192.168.121.0
        isWhiteIp : false
    */
}

 

반면, 두 번째 테스트인 given_InvalidIP__when_WhiteCidrCheck__then_False 메소드는 

포함되는 범위가 없기 때문에 허용되지 않는 요청으로 간주되어 false로 처리된 것을 확인할 수 있습니다.

 

 

 

여기서 사실 Interceptor를 테스트할 수는 없을까하는 마음에,,,

많은 삽질 끝에 결국 postman을 통해 테스트를 하게 되었습니다 🥲

 

 

Postman

생각해보니, Header로 파싱해서 IP를 가져오기 때문에 아래와 같이

Header에 IP 파싱 값을 넣어 테스트했습니다.

 

 

✔️ White IP를 입력했을 경우

 

 

접근 가능한 IP로 해당 Path의 결과값을 확인할 수 있습니다.

 

 

✔️ White IP가 아닌 IP를 입력했을 경우

 

허용되지 않는 접근으로, Interceptor에서 지정한

response.sendError(403, "IP Forbidden"); 가 적용된 것을 확인할 수 있습니다.

 

 

 

그럼 지금까지 CIDR로 허용 IP의 범위를 지정해 IP Filtering을 해보았습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!

감사합니다 ☺️