기타

특정 사용자의 IP만 허용시키기 위한 IP 필터 제작하기

indeep 2024. 11. 6. 22:24

만약에 다음과 같은 상황이 주어진다고 가정해 보자.

 

 

고객 : 이게 저희 내부망에서만 사용하고 싶은데... 혹시 내부망의 IP만 들어오고 나머지는 막아줄 수 있나요?

 

저희 아이피는 전부 135.235.xxx.xxx으로 되어있어요~

 

 

 

??? : 그게 되겠어요? 

 

 

 

네 됩니다.

 

 

먼저 서버 개념부터 같이 살펴봐야 하는 요구사항이다.

 

 

사용자가 특정 웹사이트에 접속을 하게 되면 보통 WEB -> WAS 순서대로 접속하게 된다.

(웹서버를 따로 안 두고 WAS만 둬서 사용하는 경우도 있다. WAS가 웹 서버의 역할도 같이 겸비하기 때문)

 

여기서 사용자의 요구는 본인들이 사용하는 IP 대역만 통과시켜 달라고 요청이 들어왔었다.

 

그러기 위해서는 아래처럼 WEB의 접속을 막던가, WAS의 접속을 막아야 한다.

 

 

 

만약에 아파치NGINX 같은 웹서버를 사용하고 있다면 설정을 통해서 특정 IP 대역만 허용하도록 최우선적으로 만들어야 한다. 방화벽도 그렇고 블라블라..

(가장 앞 단에서 막는 것이 베스트)

 

 

그리고 혹시 모를 경우를 대비해서 WAS에서도 IP 필터를 만들어서 적용하면 좋겠다고 판단했다.

 

 

WAS에 들어오면 스프링 시큐리티를 먼저 타기 때문에 시큐리티 필터를 탈 때 IP 필터를 같이 타도록 만들면 큰 문제가 없을 것이라 생각했다.

 

 

먼저 IpFilter라는 클래스를 하나 생성해 보자.

@Component
@Slf4j
public class IpFilter extends OncePerRequestFilter {

	/**
	 * 여기서 실제로 어떤 ip를 필터링 시킬 것인지에 대한 로직이 들어가야함.
	 * */
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		String ip = request.getRemoteAddr();

		log.info("현재 접속자의 아이피 : {}", ip);

		filterChain.doFilter(request, response);
	}

}

 

OncePerRequestFilter를 상속받아서 한 번만 필터를 동작하도록 만들 수 있다.

 

그리고 doFilterInternal 안에 실제 동작할 로직을 넣어주면 된다.

 

현재는 접속자의 ip를 먼저 출력시켜 보았다.

 

 

로컬로 실행하고 있기 때문에 ipv6인 0:0:0:0:0:0:0:1로 들어오는 것을 확인할 수 있다.

(ipv4면 127.0.0.1로 들어오게 된다.)

 

 

다시 사용자의 요구사항을 보면 135.235.xxx.xxx 이렇게 시작하는 ip만 접속하게 만들어달라고 했다.

 

 

위 아이피는 ipv4로 우리가 출력하는 기본 ip는 ipv6다. 그러니 변환 작업을 진행해야 한다.

 

 

우선 인텔리제이에서 ipv4를 기본으로 사용하게 JVM 설정을 진행해준다.

 

실행 버튼 왼쪽의 메노를 눌러서 Edit Configurations로 들어간다.

 

오른쪽에 Modify options를 누른 뒤 Add VM options를 선택한다.

 

그리고 해당 입력 칸에 아래 코드를 입력해준다.

 -Djava.net.preferIPv4Stack=true

 

 

로드밸런싱이나 프록시 서버를 이용할 경우 클라이언트의 IP로 안 들어오는 경우가 발생하게 된다.

 

이런 경우 X-Forwarded-For(XFF) 헤더를 통해 클라이언트의 원 IP 주소를 식별할 수 있다.

 

보통 아래와 같이 들어오기 때문에 첫 번째 IP를 클라이언트 IP로 식별하게 된다.

X-Forwarded-For: client, proxy1, proxy2

 

 

변환 코드는 인터넷에 많이 널려있는 코드를 사용했다.

package org.c4marathon.assignment.domain;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class IpFilter extends OncePerRequestFilter {

	@Value("${ip.filter.one}")
	private String firstIp;
	@Value("${ip.filter.two}")
	private String secondIp;

	/**
	 * 여기서 실제로 어떤 ip를 필터링 시킬 것인지에 대한 로직이 들어가야함.
	 * */
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		// ipv6 -> ipv4 변환 작업
		String ip = null;
		try {
			ip = resolveArgument(request);
		} catch (Exception e) {
			// 여기서 예외 작업을 처리하면 된다.
			throw new RuntimeException(e);
		}

		// ip 분리 진행
		String[] ipSplit = ip.split("\\.");

		if (!ipSplit[0].equals(firstIp) || !ipSplit[1].equals(secondIp)) {
			log.error("해당 아이피는 접속 차단 : {}", ip);
			response.setStatus(HttpServletResponse.SC_FORBIDDEN);
			response.setContentType("application/json; charset=UTF-8");
			response.getWriter().write("{\"message\": \"접속이 불가능한 IP입니다\"}");
			response.getWriter().flush();
			return;
		}

		log.info("해당 아이피는 접속 허용 : {}", ip);
		filterChain.doFilter(request, response);
	}

	private String resolveArgument(HttpServletRequest request) throws Exception {
		String clientIp = request.getHeader("X-Forwarded-For");
		if (StringUtils.hasText(clientIp) && !"unknown".equalsIgnoreCase(clientIp)) {
			// X-Forwarded-For 헤더가 존재할 경우, 첫 번째 IP만 사용
			return clientIp.split(",")[0].trim();
		}

		// 그 외의 경우 다른 헤더 확인
		clientIp = request.getHeader("Proxy-Client-IP");
		if (StringUtils.hasText(clientIp) && !"unknown".equalsIgnoreCase(clientIp)) {
			return clientIp;
		}

		clientIp = request.getHeader("WL-Proxy-Client-IP");
		if (StringUtils.hasText(clientIp) && !"unknown".equalsIgnoreCase(clientIp)) {
			return clientIp;
		}

		clientIp = request.getHeader("HTTP_CLIENT_IP");
		if (StringUtils.hasText(clientIp) && !"unknown".equalsIgnoreCase(clientIp)) {
			return clientIp;
		}

		clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
		if (StringUtils.hasText(clientIp) && !"unknown".equalsIgnoreCase(clientIp)) {
			return clientIp;
		}

		// 마지막으로 직접 연결된 경우
		return request.getRemoteAddr();
	}

}

 

이대로 동작시키면 아래와 같이 127.0.0.1로 변환되어서 나오게 된다.

 

 

자 그러면 앞에 있는 2개의 자리만 봐야 한다.

 

해당 ip를 static final로 박아둘 수 있지만 바뀔 경우 소스를 바꾸고 빌드, 배포해야 하기 때문에 yml로 빼는 방법을 선택했다.

 

 

yml 설정은 아래와 같이 진행.

ip:
  filter:
    one: 135
    two: 235

 

 

최종 코드는 아래와 같다.

package org.c4marathon.assignment.domain;

import java.io.IOException;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class IpFilter extends OncePerRequestFilter {

	@Value("${ip.filter.one}")
	private String firstIp;
	@Value("${ip.filter.two}")
	private String secondIp;

	/**
	 * 여기서 실제로 어떤 ip를 필터링 시킬 것인지에 대한 로직이 들어가야함.
	 * */
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		// ipv6 -> ipv4 변환 작업
		String ip = null;
		try {
			ip = resolveArgument(request);
		} catch (Exception e) {
			// 여기서 예외 작업을 처리하면 된다.
			throw new RuntimeException(e);
		}

		// ip 분리 진행
		String[] ipSplit = ip.split("\\.");

		if (!ipSplit[0].equals(firstIp) || !ipSplit[1].equals(secondIp)) {
			log.error("해당 아이피는 접속 차단 : {}", ip);
			response.setStatus(HttpServletResponse.SC_FORBIDDEN);
			response.setContentType("application/json; charset=UTF-8");
			response.getWriter().write("{\"message\": \"접속이 불가능한 IP입니다\"}");
			response.getWriter().flush();
			return;
		}

		log.info("해당 아이피는 접속 허용 : {}", ip);
		filterChain.doFilter(request, response);
	}

	private String resolveArgument(HttpServletRequest request) throws Exception {

		String clientIp = request.getHeader("X-Forwarded-For");
		if (StringUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
			//Proxy 서버인 경우
			clientIp = request.getHeader("Proxy-Client-IP");
		}
		if (StringUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
			//Weblogic 서버인 경우
			clientIp = request.getHeader("WL-Proxy-Client-IP");
		}
		if (StringUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
			clientIp = request.getHeader("HTTP_CLIENT_IP");
		}
		if (StringUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
			clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
		}
		if (StringUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
			clientIp = request.getRemoteAddr();
		}

		return clientIp;
	}

}

 

이렇게 되면 127.0.0.1로 들어오는 경우는 가장 맨 앞단에서 막히기에 모든 API가 막히게 된다.

 

반면에 135.235로 시작하는 IP는 허용되는 IP로 보고 다음 필터로 넘겨주게 된다.

 

 

시큐리티에서는 해당 필터를 먼저 타도록 만들기 위해 아래와 같이 설정해 주었다.

@RequiredArgsConstructor                                                                                   
public class SecurityConfig {                                                                              
                                                                                                           
	private final JwtFilter jwtFilter;                                                                     
	private final IpFilter ipFilter;                                                                       
                                                                                                           
	@Bean                                                                                                  
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {                   
		http                                                                                               
			.csrf(AbstractHttpConfigurer::disable)                                                         
			.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
			.addFilterBefore(ipFilter, UsernamePasswordAuthenticationFilter.class)                         
			.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)                        
			.authorizeHttpRequests(authorize -> authorize                                                  
				.requestMatchers("/api/exists/**", "/api/login", "/api/sign-up", "/api/boards").permitAll()
				.anyRequest().authenticated());                                                            
                                                                                                           
		return http.build();                                                                               
	}

 

 

 

위에서 만든 ip 필터링은 어떻게 보면 간단하게 만든 필터링 기능이다. 

 

실제로는 방화벽이나 web에 접속할 때 최우선적으로 막아야 하고, 혹시 뚫리는 경우를 대비해서 was에서도 한번 걸러주는 작업을 진행해야 한다.

 

 

다음에는 항상 궁금했던 점인데 왜 시큐리티 필터를 타게 만들려면 UsernamePasswordAuthenticationFilter.class 이전에 타게 만들까?라는 주제에 대해 조사해 봐야겠다.

반응형