만약에 다음과 같은 상황이 주어진다고 가정해 보자.
고객 : 이게 저희 내부망에서만 사용하고 싶은데... 혹시 내부망의 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 이전에 타게 만들까?라는 주제에 대해 조사해 봐야겠다.
'기타' 카테고리의 다른 글
SpringBoot + React로 실시간 채팅을 구현해보기(feat 설날) (0) | 2025.02.01 |
---|---|
크롬 다중 세션을 쉽게 이용해보자(feat 인간 수동 테스트) (1) | 2025.01.25 |
마이바티스 H2를 이용한 통합 테스트 환경 구축 방법 (1) | 2024.10.03 |
PK를 설정하는 방법(AutoIncrement, UUID) (2) | 2024.09.29 |