Spring Security 6 hasIpAddress 사용하는법 (feat.Spring boot 3)

들어가며

이번 프로젝트에서 WhiteIpList 관리 기능 부분을 맡아 만들게 되어 해당 기능을 Security 를 이용해서 붙여보려 했었다. 결국은 다르게 구현했지만 Security 로는 어떻게 할 수 있을까 공부 해보는 좋은 시간이었다.

 

Spring Security 가 6버전으로 올라오며 많은것들이 바뀌었다. 이전에 사용하던 authorizeRequests()authorizeHttpRequests()Deprecated 되고, authorizeHttpRequests(Customizer) 를 사용되게 권장되게 바뀌었다.

HttpSecurity.authorizeRequests()
HttpSecurity.authorizeHttpRequests()

그러면서 메서드들의 사용법이 조금씩 바뀌었는데 그 중 제법 많이 쓰이던 hasIpAddress 메서드는 authorizeHttpRequests 를 사용하면 존재하지 않는다. 그럼 어떻게 hasIpAddress 대신 ip 필터링을 할 수 있을까?

 

기존 사용 방법

기존 hasIpAddress 는 밑의 방식으로 사용했었다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
          .antMatchers("/login").permitAll()
          .antMatchers("/foos/**").hasIpAddress("11.11.11.11")
          .anyRequest().authenticated()
          .and()
          .formLogin().permitAll()
          .and()
          .csrf().disable();
    }

    // 출처 https://www.baeldung.com/spring-security-whitelist-ip-range

}

hasIpAddress 의 내부를 보면 밑의 코드를 볼 수 있다.

ExpressionUrlAuthorizationConfigurer.java
ExpressionUrlAuthorizationConfigurer.java

결국 hasIpAddress(IP).access("hasIpAddress('" + IP + "')") 와 같은 의미 인것이다. access 메서드 파라미터로 String 으로 쓸 수 있는건 Spring 에서 지원해주는 Web Security Expressions(Spring Security 6.0.7 docs) 덕분이다. 하지만 해당 카테고리는 현재 Spring Security 6.1.4 docs 에서는 찾을수 없다. type safe 문제로 migration 하라고 권장하고 있다.(관련문서

 

현재 사용 방법

현재 authorizeHttpRequests메서드를 사용하면 access 에 String 이 아닌 AuthorizationManager<RequestAuthorizationContext> 타입을 받는다.

AuthorizeHttpRequestsConfigurer.java

AuthorizationManager 는 check 라는 메서드를 가진 FunctionalInterface이다.

AuthorizationManager.java

authentiocation 객체와 T 타입인 RequestAuthorizationContext 객체를 살펴보면 이런 값들을 가지고 있다.

debug 로 살펴본 객체

해당 check 메서드의 구현을 통해 request 객체를 받아 AuthorizationDecision 을 리턴해 처리를 할 수 있다. 해당 request 객체 안에 요청자의 정보를 이용해 Ip 검사를 하고 AuthorizationDecision 에는 boolean 값을 넣어 처리하면 된다. 

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    public static final String ALLOWED_IP_ADDRESS = "111.111.111.111";
    public static final String SUBNET = "/32";
    public static final IpAddressMatcher ALLOWED_IP_ADDRESS_MATCHER = new IpAddressMatcher(ALLOWED_IP_ADDRESS + SUBNET);
    public static final String IP_CHECK_PATH_PREFIX = "/api/temp";
    public static final String IP_CHECK_PATH_PATTERN = IP_CHECK_PATH_PREFIX + "/**";

    @Bean
    public SecurityFilterChain securityFilterChain(final HttpSecurity httpSecurity) throws Exception {
        return httpSecurity.authorizeHttpRequests((authorize) ->
                authorize
                        .requestMatchers(IP_CHECK_PATH_PATTERN).access(this::hasIpAddress)
                        .anyRequest().authenticated()
        ).build();
    }

    private AuthorizationDecision hasIpAddress(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        return new AuthorizationDecision(ALLOWED_IP_ADDRESS_MATCHER.matches(object.getRequest()));
    }

}

Spring Security 가 가진 IpAddressMatcher 클래스를 이용해 허용 가능한 Ip 대역을 설정한다. 해당 Ip list 는 DB 를 통해 관리된다면 필요한순간 생성해서 사용해도 된다. Ip 를 검사할 path 를 지정하고 해당 path 로 요청이 들어오면 Ip 검사를 하게 된다. IpAddressMatcher. matches()에 request 객체를 그냥 넘기면 request 의 getRemoteAddr() 메서드를 사용한다.

IpAddressMatcher.java

주의 ! getRemoteAddr()

앞단에 Proxy 가 있는 경우 제대로 된 Ip 를 가져오지 못하므로 해당 관련쪽 처리를 따로 해서 Ip 를 추출해야한다.

 

만약 access 에서 요청자가 인증이 된 사용자인지 확인하려면 authentioncation 객체를 이용해 검사해야한다. 여기서 특이하게 인증되지 않은 사용자인 anonymousUser 도 authenticated 값이 true 값을 가지고 있다. 해당 이유는 stackoverflow 에서 찾을 수 있었다. 그래서 인증된 유저인지 검사를 instanceof 를 통해 진행했다.

 

최종코드

@Configuration
@EnableWebSecurity
@EnableWebMvc // Test 를 위해 임시 적용
public class SecurityConfig {
    public static final String ALLOWED_IP_ADDRESS = "111.111.111.111";
    public static final String SUBNET = "/32";
    public static final IpAddressMatcher ALLOWED_IP_ADDRESS_MATCHER = new IpAddressMatcher(ALLOWED_IP_ADDRESS + SUBNET);
    public static final String IP_CHECK_PATH_PREFIX = "/api/temp";
    public static final String IP_CHECK_PATH_PATTERN = IP_CHECK_PATH_PREFIX + "/**";

    @Bean
    public SecurityFilterChain securityFilterChain(final HttpSecurity httpSecurity) throws Exception {
        return httpSecurity.authorizeHttpRequests((authorize) ->
                authorize
                        .requestMatchers(IP_CHECK_PATH_PATTERN).access(this::hasIpAddress)
                        .anyRequest().authenticated()
        ).build();
    }

    private AuthorizationDecision hasIpAddress(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        return new AuthorizationDecision(
                        !(authentication.get() instanceof AnonymousAuthenticationToken)
                                && ALLOWED_IP_ADDRESS_MATCHER.matches(object.getRequest()
                        ));
    }

}

 

테스트코드

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = SecurityConfig.class)
@WebAppConfiguration
@WithMockUser
public class SecurityIpTest {

    public static final String NOT_ALLOWED_ID_ADDRESS = "11.11.11.11";

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @Test
    void IP_CHECK_PATH_외에는_ip_검사를_하지않는다() throws Exception {
        mvc.perform(get("/"))
                .andExpect(allowed());
    }

    @Test
    void IP_CHECK_PATH_접근시_ip_검사를_해야한다() throws Exception {
        mvc.perform(get(IP_CHECK_PATH_PREFIX))
                .andExpect(notAllowed());
    }

    @Test
    void IP_CHECK_PATH_접근시_허용된_ip_가_아니면_권한이_없어야한다() throws Exception {
        mvc.perform(get(IP_CHECK_PATH_PREFIX).with(remoteAddr(NOT_ALLOWED_ID_ADDRESS)))
                .andExpect(notAllowed());
    }

    @Test
    void IP_CHECK_PATH_접근시_허용된_ip_와_인증된_유저는_통과해야한다() throws Exception {
        mvc.perform(get(IP_CHECK_PATH_PREFIX).with(remoteAddr(ALLOWED_IP_ADDRESS)))
                .andExpect(allowed());
    }

    @Test
    @WithAnonymousUser
    void IP_CHECK_PATH_접근시_허용된_ip_지만_인증되지_않은_유저는_권한이_없어야한다() throws Exception {
        mvc.perform(get(IP_CHECK_PATH_PREFIX).with(remoteAddr(ALLOWED_IP_ADDRESS)))
                .andExpect(notAllowed());

    }

    private static ResultMatcher allowed() {
        return status().isNotFound();
    }

    private static ResultMatcher notAllowed() {
        return status().isForbidden();
    }

    private static RequestPostProcessor remoteAddr(final String remoteHost) {
        return request -> {
            request.setRemoteAddr(remoteHost);
            return request;
        };
    }

}

전체 코드는 깃허브를 통해 확인 할 수 있다.

참고

https://docs.spring.io/spring-security/reference/servlet/authorization/index.html

https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/setup.html

https://stackoverflow.com/questions/26101738/why-is-the-anonymoususer-authenticated-in-spring-security/26117007#26117007

https://github.com/spring-projects/spring-security/issues/13474