반응형

1. 개요

 - Interceptor와 WHOIS OpenAPI를 사용하여 해외에서 접근하는 IP를 차단해보자.

 


2. 환경

 - SpringBoot

 - JDK 1.8

 


3. 구현

 - 핵심 로직은 크게 2가지이다. Client의 요청을 Controller 앞단에서 처리되게 할 Interceptor, WHOIS API 통신을 위한 CloseableHttpClient.

 

 1) IPCheckInterceptor.java

@Component
public class IPCheckInterceptor implements HandlerInterceptor, Constants {
	
	@Autowired
	private WSOpenAPIService WSService;

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
	
		String clientIp = request.getHeader("X-Forwarded-For");
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("WL-Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_CLIENT_IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getRemoteAddr();
	    }
		
	    //로컬 테스트 시 주석 해제해주세요. 미국 IP입니다.
	    //clientIp = "54.211.120.28";
	    
	    if(!LOCAL_HOST.equals(clientIp)) {
	    	Map<String,String> clientInfo = WSService.getClientInfoByIPAddress(clientIp);
	    	
	    	if(clientInfo == null) {
	    		logger.error("IP에 대한 클라이언트 정보 조회에 실패하였습니다.");
	    		return false;
	    	}
	    	
	    	String country = clientInfo.get(WHO_IS_COUNTRY_CODE);

	    	if(!KOREA_COUNTRY_CODE.equals(country)) {
	    		logger.error("해외 IP가 감지되었습니다. 접근을 차단합니다. IP : {}, Country : {}", clientIp, country);
	    		return false;
	    	}
	    }
	    
	    return true;
	}
}

 - 'Client IP 추출 > WHOIS Service 메서드 실행 > 국적 코드 확인 > 접근 차단' 로직을 수행한다.

 

 - preHandler Method : Controller에 접근하기 이전에 수행되는 메서드이다. 메서드 내부에는 웹서버나 프록시를 거쳐 들어온 클라이언트의 IP를 request Header에서 추출하고, WHOIS API를 호출하는 로직이 포함되어있다.

 

 - !LOCAL_HOST.equals(clientIp) : LOCAL_HOST는 127.0.0.1 값에 대한 상수으로 인터페이스에 정의해두었다. 로컬 테스트 시 API를 태울 필요가 없어 추가하였다.

 

 - WSService.getClientInfoByIpAddress(String clientIp) : WHOIS API에 대한 서비스 클래스이다.

 

 - WHO_IS_COUNTRY_CODE, KOREA_COUNTRY_CODE : 각각 countryCode, KR 문자열에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

 

2) WSOpenAPIService.java

@Service
public class WSOpenAPIService implements Constants{
	
	@Value("${whois.api.key}")
	private String apiKey; 
	
	@Value("${whois.api.uri}")
	private String apiUri; 
	
	@Autowired
	private CloseableHttpClient closeableHttpClient;
	
	@Autowired
	private RequestConfig requestConfig;
	
	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@SuppressWarnings("unchecked")
	public Map<String,String> getClientInfoByIPAddress(String ip) {
		
		ObjectMapper objectMapper = null;

		try {
			List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
			
			nameValuePairs.add(new BasicNameValuePair("query",ip));
			nameValuePairs.add(new BasicNameValuePair("key",apiKey));
			nameValuePairs.add(new BasicNameValuePair("answer","json"));
			
			HttpGet httpGet = new HttpGet(apiUri);
			httpGet.setConfig(requestConfig);
			httpGet.addHeader("Content-type", "application/json");
			
			URI uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();

			httpGet.setURI(uri);
			
			CloseableHttpResponse response = closeableHttpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				logger.info("WHO IS API Response json : "+json);
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);

				return map.get(WHO_IS);
				
			}
			return null;
		} catch (ClientProtocolException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (URISyntaxException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (IOException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		}
	}
	
}

 - ConnectionPoolHttpClient를 사용하여 WHOIS API 서버로 통신 및 응답 값을 추출하는 로직을 수행한다.

 - 통신에 성공할 경우 ObjectMapper를 사용하여 whois 값을 Map 형태로 변환한다.

 - WHO_IS : whois 값에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

3) HttpClientConfig.java

@Configuration
public class HttpClientConfig {

	private static final int MAX_CONNECTION_PER_ROUTE = 20;
	private static final int MAX_CONNECTION_TOTAL = 200;
	private static final int CONNECTION_TIMEOUT = 10;
	private static final int SOCKET_TIMEOUT = 5;
	private static final int CONNECTION_REQUEST_TIMEOUT = 5;
	
	@Bean
	public CloseableHttpClient closeableHttpClient() {
		CloseableHttpClient closeableHttpClient = 
				HttpClients.custom().setConnectionManager(poolingHttpClientConnectionManager()).build();
		
		return closeableHttpClient;
		 
	}
	
	private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
		PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
		connectionManager.setDefaultMaxPerRoute(MAX_CONNECTION_PER_ROUTE);
		connectionManager.setMaxTotal(MAX_CONNECTION_TOTAL);
		return connectionManager;
	}
	
	@Bean
	public RequestConfig requestConfig() {
		RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(SOCKET_TIMEOUT * 1000)
                .setConnectTimeout(CONNECTION_TIMEOUT * 1000)
                .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT * 1000)
                .build();
		
		return requestConfig;
	}
}

 - ConnectionPool HttpClient 에 대한 config 클래스이다.

 - requestConfig, closeableHttpClient를 Bean으로 등록해 사용한다.

 - closeableHttpClient Bean 생성 시 커스텀한 PoolingHttpClientConnectionManager 객체를 주입시킨다.

 

 - 각 상수에 대한 설명은 다음과 같다.

상수 의미
MAX_CONNECTION_PER_ROUTE  CONNECTION 하나의 ROUTE에 연결 가능한 최대 CONNECTION 수
MAX_CONNECTION_TOTAL CONNECTION POOL에 저장될 수 있는 최대 CONNECTION 수
CONNECTION_TIMEOUT 커넥션 (3-HAND)을 맺는 시간에 대한 TIMEOUT
 SOCKET_TIMEOUT 커넥션을 맺은 후 응답을 받는 시간에 대한 TIMEOUT
CONNECTION_REQUEST_TIMEOUT CONNECTION POOL에서 CONNECTION을 꺼내오는 시간에 대한 TIMEOUT

 

4) WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private IPCheckInterceptor ipCheckInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(ipCheckInterceptor) .addPathPatterns("/**")
		.excludePathPatterns("/js/**", "/css/**", "/img/**","/assets/**");

	}

	
}

 - js, css, img, assets 과 같은 정적 페이지 요청을 제외한 모든 요청에 대해 ipCheckInterceptor를 적용시킨다.

 

 

5) IP V4 설정

 - 클라이언트로부터 추출되는 IP가 IPv6 형식이라면 IPv4 형식으로 변경해야한다. STS 환경이라면 'Run Configurations /  Spring Boot App / Arguments' 설정의 VM arguments 값에 '-Djava.net.preferIPv4Stack=true' 값을 넣어준다.

VM arguments 설정

 - 만약 외부 tomcat에서 운용된다면 catalina.sh 파일에 JVM 설정을 추가해준다.

catalina.sh 설정


4. 테스트

 - 로컬에서 테스트 시 IP가 127.0.0.1로 들어오기 때문에 IPCheckInterceptor에서 IP 값을 임의의 미국 IP로 변경한 후 테스트를 진행하였으며, 다음과 같이 API 응답 값을 얻고 접근을 차단하였다. 실제 클라이언트에서는 다음과 같이 빈 화면이 조회되게 된다.

INFO  2022-02-18 01:29:24[http-nio-8088-exec-1] [WSOpenAPIService:87] - WHO IS API Response json : {"whois":{"query":"54.211.120.28","queryType":"IPv4","registry":"ARIN","countryCode":"US"}}
ERROR 2022-02-18 01:29:24[http-nio-8088-exec-1] [IPCheckInterceptor:66] - 해외 IP가 감지되었습니다. 접근을 차단합니다. IP : 54.211.120.28, Country : US

요청이 차단된 화면

 


5. 마치며

 API 통신을 하여 국가코드를 조회하는 방식을 구현하고 나니, 동접자가 많아져 Connection이 모두 사용될 경우 서비스 속도가 느려질수 있겠다라는 생각이 들었고 DB 기반의 geoIp를 사용하는 이유도 이해가 갔다. 

 

 혹시 이 글을 보시는 분들 중 geoIp를 사용해보신 분이 있다면 댓글로 후기를 남겨주셨으면 좋겠다 ㅎㅎ;

(Help...)

반응형

+ Recent posts