상세 컨텐츠

본문 제목

Spring Security - FilterSecurityInterceptor 이해 및 Example

Spring/Spring Security

by Chan.94 2024. 11. 11. 08:00

본문

반응형

Intro

Spring Security의 인증과 인가에 대한 내용을 복기해 보자.

  • Authentication(인증) : 해당 사용자가 본인이 맞는지 확인하는 절차.
  • Authorization(인가) : 인증된 사용자가 요청된 자원에 접근가능한가를 결정하는 절차

Spring Security + JWT 인증 (2/2) - Spring Security 설정

Spring Security + JWT 인증 (1/2) - JWT 구현

 

JWT를 통해 인증을 진행하도록 구성하였다.

FilterSecurityInterceptor를 적용하여 인가에 대한 내용을 정리해 보자.


FilterSecurityInterceptor

  • 인가처리를 담당하는 필터
  • 시큐리티 필터들 중 마지막에 위치하며 인증된 사용자의 특정 요청 승인/거부 최종 결정
  • 인증객체 없이 보호자원에 접근 시도하면 AuthenticationException
  • 인증 후 자원 접근 권한이 없을 경우 AccessDeniedException
  • 권한 처리를 AccessDecisionManager에게 위임

Flow는 그림과 같다.

SecurityMetadataSource와 AccessDecisionManager에 대해 우선 정리해 보자.


SecurityMetadataSource

SecurityMetadataSource는 클라이언트가 요청한 자원에 필요한 권한을 전달해 주는 역할을 한다.

 

SecurityMetadataSource는 최상위 인터페이스로써 getAttributes, getAllConfigAttributes, supports 메서드를 가지고 있다.

  • FilterInvocationSecurityMetadataSource - Url 권한 정보를 추출하는 인터페이스
  • MethodSecurityMetadataSource - Method 권한 정보를 추출하는 인터페이스

 

URL이 어떤 권한을 가지고 있는지 DB에서 조회한다.

권한정보가 없을 시 null을 반환하면 된다.

아래 예시에서 FilterInvocationSecurityMetadataSource를 구현할 예정이다.


AccessDecisionManager

Access 결정을 내리는 인터페이스로, 구현체 3가지를 기본으로 제공한다.

  • AffirmativeBased : 여러 Voter 중에 한 명이라도 허용하면 허용, 기본전략
  • ConsensusBased : 다수결
  • UnanimousBased : 만장일치

AccessDecisionManager는 여러 개의 AceessDecisionVoter를 가질 수 있다.

 

AccessDecisionVoter

실질적으로 사용자 요청에 대해서 자원에 접근할 자격이 있는지 판단한다.

Voter가 권한 부여 과정에서 판단하는 자료(파라미터)는 세 가지다.

  • Authentication - 인증 정보
  • FilterInvocation - 요청 정보
  • ConfigAttributes - 권한 정보

결정 방식

  • ACCESS_GRANTED : 접근 허용
  • ACCESS_DENIED : 접근 거부
  • ACCESS_ABSTAIN : 접근 보류

 

이제 소스를 적용해 보자.


FilterInvocationSecurityMetadataSource구현

public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    // URL 패턴과 해당 권한을 매핑할 Map
    private final Map<RequestMatcher, Collection<ConfigAttribute>> requestMap = new LinkedHashMap<>();

    public UrlFilterInvocationSecurityMetadataSource() {
        // URL 패턴과 권한 매핑 (예시)
        requestMap.put(new AntPathRequestMatcher("/admin/**"), Collections.singletonList(new SecurityConfig("ADMIN")));
        requestMap.put(new AntPathRequestMatcher("/api/v1/**"), Arrays.asList(new SecurityConfig("ADMIN"), new SecurityConfig("USER")));
    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        HttpServletRequest request = ((FilterInvocation) object).getRequest();
        
        for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()) {
            if (entry.getKey().matches(request)) {
                return entry.getValue();
            }
        }
        return null;  // 매칭되는 권한이 없으면 null 반환 (접근 불가)
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        Set<ConfigAttribute> allAttributes = new HashSet<>();
        requestMap.values().forEach(allAttributes::addAll);
        return allAttributes;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

}

 

gerAttributes에 request에 할당되어 있는 권한정보를 조회하여 ConfigAttribute에 담는다.

위에 소스는 예시를 위해 생성자에 임의로 ADMIN, USER권한을 부여했다.

 


AccessDecisionVoter 구현

@Slf4j
public class DBAccessDecisionVoter implements AccessDecisionVoter<FilterInvocation> {
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute instanceof SecurityConfig;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz != null && clazz.isAssignableFrom(FilterInvocation.class);
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {

        SecurityConfig securityConfig = null;
        boolean containAuthority = false;
        for (final ConfigAttribute configAttribute : attributes) {
            if (configAttribute instanceof SecurityConfig) {
                securityConfig = (SecurityConfig) configAttribute;
                for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
                    containAuthority = securityConfig.getAttribute().equals(grantedAuthority.getAuthority());
                    if (containAuthority) {
                        break;
                    }
                }
                if (containAuthority) {
                    break;
                }
            }
        }return containAuthority ? ACCESS_GRANTED : ACCESS_DENIED;
    }
}

AccessDecisionVoter는 실질적으로 사용자 요청에 대해서 자원에 접근할 자격이 있는지 판단한다고 했다.

Authentication, FilterInvocation, ConfigAttributes를 이용하여 판단한다.

인가가 되기 전 JWT를 통해 사용자 인증을 진행하였기에 Authentication에는 사용자 정보가 있을 것이다.

ConfigAttributes는 위에서 URL에 접근가능한 권한목록이 들어있을 것이다.

따라서, Authentication에 있는 사용자의 권한목록과 URL에 접근가능한 권한목록을 비교하여 판단하는 것이다.


Configuration적용

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AffirmativeBased;
import org.springframework.security.access.vote.RoleVoter;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import com.devlog.common.filter.JwtAuthenticationFilter;
import com.devlog.common.filter.PermitAllFilter;
import com.devlog.common.jwt.JwtAccessDeniedHandler;
import com.devlog.common.jwt.JwtAuthenticationEntryPoint;
import com.devlog.common.jwt.JwtProvider;
import com.devlog.common.mci.RequestHeader;
import com.devlog.common.security.UrlFilterInvocationSecurityMetadataSource;import com.devlog.security.DBAccessDecisionVoter;

import lombok.RequiredArgsConstructor;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity//Spring Security 사용
public class SecurityConfig {

    @Value("${permit.url}")
    private final String[] PERMIT_ALL_RESOURCES;
    
    private final JwtProvider jwtProvider;
    
    private final RequestHeader requestHeader;
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
    @Bean
    public PermitAllFilter customFilterSecurityInterceptor(AuthenticationManager authManager) throws Exception {
        PermitAllFilter permitAllFilter = new PermitAllFilter(PERMIT_ALL_RESOURCES);
        permitAllFilter.setSecurityMetadataSource(this.urlFilterInvocationSecurityMetadataSource());
        permitAllFilter.setAccessDecisionManager(this.affirmativeBased());
        permitAllFilter.setAuthenticationManager(authManager);
        return permitAllFilter;
    }
    /*
     * AccessDecisionManager의 3가지 정책중 하나를 사용합니다.
        - AffirmativeBased : 여러 Voter 중에 한명이라도 허용하면 허용, 기본전략
        - ConsensusBased : 다수결
        - UnanimousBased : 만장일치
    */
    private AccessDecisionManager affirmativeBased() {
        return new AffirmativeBased(this.getAccessDecisionVoters());
    }
    private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
        List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
        accessDecisionVoters.add(new DBAccessDecisionVoter());
        return accessDecisionVoters;
    }
    private FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
        return new UrlFilterInvocationSecurityMetadataSource();
    }
    /**
     * SecurityFilterChain 정의
     */
    @Bean
    protected SecurityFilterChain configure(HttpSecurity httpSecurity, AuthenticationManager authenticationManager) throws Exception {
        httpSecurity
            // JWT를 사용할 것이기 때문에 HTTP Basic 인증 disable
            .httpBasic(httpBasic -> httpBasic.disable())    
            // security의 기본 로그인 화면을 비활성화
            .formLogin(formLogin -> formLogin.disable())    
            // 모든요청에 JWT 인증을 하므로, CSRF 보호가 필요하지 않다.
            .csrf(csrf -> csrf.disable())       
            // JWT 인증방식으로 Session은 필요 없음
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // request 사용권한 체크(FilterSecurityInterceptor에서 체크)
            .authorizeHttpRequests(authorize -> authorize
                  .requestMatchers(new AntPathRequestMatcher("/**")).permitAll()
                    .anyRequest().authenticated())
            /**
             * exception handling
             * 인증, 인가 과정에서 발생할 수 있는 예외를 처리하는 방법을 설정
             */
            .exceptionHandling(exception -> exception
                    .accessDeniedHandler(new JwtAccessDeniedHandler())              //인가(Authorization) 실패시
                    .authenticationEntryPoint(new JwtAuthenticationEntryPoint()))   //인증(Authentication) 실패시
            // JWT 인증 필터를 id/password 인증 필터 이전에 추가
            .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, requestHeader, PERMIT_ALL_RESOURCES), UsernamePasswordAuthenticationFilter.class)
            // 권한 체크
            .addFilterBefore(this.customFilterSecurityInterceptor(authenticationManager), FilterSecurityInterceptor.class)
            ;
        
        return httpSecurity.build();
    }

}

 

/*
 * AccessDecisionManager의 3가지 정책중 하나를 사용합니다.
    - AffirmativeBased : 여러 Voter 중에 한명이라도 허용하면 허용, 기본전략
    - ConsensusBased : 다수결
    - UnanimousBased : 만장일치
*/
private AccessDecisionManager affirmativeBased() {
    return new AffirmativeBased(this.getAccessDecisionVoters());
}
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
    List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
    accessDecisionVoters.add(new DBAccessDecisionVoter());
    return accessDecisionVoters;
}

AccessDecisionManager의 정책을 정의하고 AccessDecisionVoter를 등록한다.

Voter는 N개 등록 할 수 있다.

 

private FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
    return new UrlFilterInvocationSecurityMetadataSource();
}

위에서 정의한 FilterInvocationSecurityMetadataSource 구현체.

URL에 대한 권한정보를 조회한다.

 

@Bean
public PermitAllFilter customFilterSecurityInterceptor(AuthenticationManager authManager) throws Exception {
    PermitAllFilter permitAllFilter = new PermitAllFilter(PERMIT_ALL_RESOURCES);
    permitAllFilter.setSecurityMetadataSource(this.urlFilterInvocationSecurityMetadataSource());
    permitAllFilter.setAccessDecisionManager(this.affirmativeBased());
    permitAllFilter.setAuthenticationManager(authManager);
    return permitAllFilter;
}

PermitAllFilter는 FilterSecurityInterceptor를 상속받은 CustomFilterSecurityInterceptor이다.

권한체크하지 않을 PERMIT_ALL_RESOURCES를 제외하고 권한체크를 진행한다는 것만 알고 넘어가자.

 

.addFilterBefore(this.customFilterSecurityInterceptor(authenticationManager), FilterSecurityInterceptor.class)

 

FilterSecurityInterceptor앞에 새로 정의한 CustomFilterSecurityInterceptor를 위치시켜 주자.

FilterSecurityInterceptor 가 두 번 실행되지 않을까라는 생각을 할 수 있다. 한번 인가 처리가 되었으면 다음번엔 체크하지 않는다.

 


마무리

다이어그램과 Flow를 보고 정리해 보자.



  • FilterSecurityInterceptor가 실행되는 시점에는 앞단에서 JWT를 이용하여 인증(Authentication)이 된 상태이다.
  • FilterInvocationSecurityMetadataSource에서 요청 URL과 매칭되는 권한이 있는지 확인(DB조회)하여 ConfigAttribute에 담는다. null인 경우 인가처리가 진행되지 않는다.
  • AccessDecisionManager가 해당 URL에 대하여 인가에 대한 결정을 내리게 된다.
    Authentication, FilterInvocation, ConfigAttributes를 이용하여 판단한다.
    Authentication에는 사용자 정보가 있을 것이다.
    ConfigAttributes는 위에서 URL에 접근가능한 권한목록이 들어있을 것이다.
    따라서, Authentication에 있는 사용자의 권한목록과 URL에 접근가능한 권한목록을 비교하여 판단하는 것이다.
반응형

관련글 더보기

댓글 영역

>