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' 값을 넣어준다.
- 만약 외부 tomcat에서 운용된다면 catalina.sh 파일에 JVM 설정을 추가해준다.
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...)
'백엔드 > JAVA' 카테고리의 다른 글
[JAVA] 템플릿/콜백 패턴 적용해보기 (0) | 2023.05.24 |
---|---|
[Java] JWT 개념 정리 (0) | 2023.03.20 |
[Java] WHOIS OpenAPI를 사용한 해외 IP 차단 기능 구현 (1) (0) | 2022.02.13 |
[JAVA] DefaultHttpClient와 CloseableHttpClient의 차이 2 (1) | 2021.08.25 |
[JAVA] DefaultHttpClient와 CloseableHttpClient의 차이 1 (0) | 2021.08.24 |