[Spring Security] Spring Security Modules & Custom

develemon·2024년 3월 26일

Spring Security

목록 보기
1/1
post-thumbnail

Spring Security


Spring 기반의 어플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크로, Spring Security는 '인증'과 '권한'에 대한 부분을 Filter 흐름에 따라 처리하고 있다. Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받지만(웹 컨테이너에서 관리), Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점(스프링 컨테이너에서 관리)에서 적용 시기의 차이가 있다.

즉, 스프링 프레임워크로 개발된 Spring Security는 Spring의 구성 요소 및 기능을 활용하여 보안 관련 기능을 제공하지만, Spring의 일반적인 Bean이나 Configuration과는 다소 다른 방식으로 동작한다. Spring Security는 스프링 애플리케이션의 보안 부분을 담당하며, 보안 필터들을 웹 애플리케이션의 Filter Chain에 추가함으로써 요청을 처리하기 전에 보안 검사를 수행한다. 따라서 Spring Security는 개발의 영역과 구분 지으면서 보안과 관련해 많은 옵션을 제공해주기 때문에 개발자는 일일이 보안 관련 로직을 작성하지 않아도 된다.

그럼 Spring Security의 내부로 조금 들어가보자.

Spring Security 인증 과정


Spring Security의 사용자 인증 처리 과정은 다음과 같다.

  1. 사용자가 로그인 정보와 함께 인증 요청을 한다.(Http Request)
  2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
  3. AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
  4. AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다.
  5. 실제 DB에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.
  6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.
  7. AuthenticationProvider(들)은 UserDetails를 넘겨받고 사용자 정보를 비교한다.
  8. 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.
  9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.
  10. Authenticaton 객체를 SecurityContext에 저장한다.

이처럼 Spring Security는 여러 모듈을 통해 사용자 인증 정보를 처리하게 되는데, 이번에는 그 모듈들에 대해서도 알아보도록 하자.

Spring Security Modules


AuthenticationFilter

AuthenticationFilter는 Spring Security에서 사용자의 인증을 처리하는 데 사용되는 기본적인 필터로, 이 필터는 실제로 인증을 수행하지는 않지만, 사용자의 인증 요청을 가로채어 AuthenticationManager에 전달한다. 실제 인증 메커니즘은 AuthenticationManager와 이에 연결된 AuthenticationProvider에서 수행되는데, AuthenticationFilter는 사용자가 인증되었는지 여부를 확인하고, 인증되지 않은 경우 인증을 위임하는 데에 주요 목적을 갖는다.

UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationTokenAuthentication을 구현한 AbstractAuthenticationToken의 하위 클래스로, 사용자 정보의 usernameprincipal 역할을 하고, passwordcredentials 역할을 한다. UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두 번째는 인증이 완료된 객체를 생성한다. 일반적으로 사용자가 로그인 폼을 통해 제출한 자격 증명을 나타내는 데 사용되며, AuthenticationManager에 의해 처리되고, 사용자 인증에 성공한 후에는 SecurityContextHolder에 저장된다.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 620L;
    private final Object principal;
    private Object credentials;

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }
    
    ...
}

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter에 대해서도 같이 알아보면, 이는 Spring Security의 AbstractAuthenticationProcessingFilter를 확장한 것으로, 이 필터는 사용자가 인증을 시도할 때 사용자의 usernamepassword를 수집하고, 이를 기반으로 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달한다. 일반적으로 /login 엔드포인트와 연결되어 기본 로그인 페이지를 통해 사용자가 로그인할 때 이 필터가 작동하며, 이때 attemptAuthentication() 메서드가 자동으로 호출된다.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username.trim() : "";
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    ...
}

범용 필터인 AuthenticationFilter와는 다르게 UsernamePasswordAuthenticationFilter는 특정 유형의 인증(username/password)을 처리하는 구체적인 필터라는 차이를 갖는다.

AuthenticationManager

인증에 대한 부분은 AuthenticationManager를 통해서 처리하게 되는데, 실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider에 의해 처리된다.

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

AuthenticationProvider

AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데, 인증 전의 Authentication 객체를 받아서 주어진 인증 유형에 따라 인증 후 인증이 완료된 객체를 반환하는 역할을 한다. 아래와 같은 인터페이스를 구현해 Custom AuthenticationProvider를 작성하고 AuthenticationManager에 등록하면 된다.

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}

ProviderManager

AuthenticationManager를 구현한 ProviderManagerAuthenticationProvider를 구성하는 목록을 갖는다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    private List<AuthenticationProvider> providers;

    ...
    
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        int currentPosition = 0;
        int size = this.providers.size();
        Iterator var9 = this.getProviders().iterator();

        while(var9.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var9.next();
            if (provider.supports(toTest)) {
                ...
                try {
                    result = provider.authenticate(authentication);
                    ...
                    
}

UserDetailsService

UserDetailsServiceUserDetails 객체를 반환하는 하나의 메소드만을 갖고 있는데, 일반적으로 이를 구현한 클래스에 UserRepository를 주입받아 DB와 연결하여 처리한다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetails

Spring Security는 사용자 세부 정보를 UserDetails 인터페이스의 구현체로 표현하며, 인증에 성공하여 생성된 UserDetails 객체는 Authentication 객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다.

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

SecurityContextHolder

SecurityContextHolder는 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다. SecurityContextHolder를 통해 현재 사용자의 SecurityContext를 검색할 수 있다.

SecurityContext

Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication을 저장하거나 꺼내올 수 있다.

public interface SecurityContext extends Serializable {
    Authentication getAuthentication();

    void setAuthentication(Authentication authentication);
}

GrantedAuthority

GrantedAuthority는 현재 사용자(Principal)가 가지고 있는 권한을 의미하며, ROLE_USERROLE_ADMIN 같이 ROLE_*의 형태로 사용한다. GrantedAuthority 객체는 UserDetailsService에 의해 불러올 수 있고, 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 결정한다.

public interface GrantedAuthority extends Serializable {
    String getAuthority();
}

Custom 필터 및 모듈


지금까지 Spring Security의 기본 모듈들에 대해 알아봤다. 그런데 많은 경우 Spring Security가 제공하는 기본 인증 유형이 아닌 경우에 다른 Custom 필터 및 모듈을 만들어 사용하게 된다. 그 이유는 다음과 같다.

  1. 다양한 인증 방식 지원: UsernamePasswordAuthenticationFilterusernamepassword를 이용한 폼 기반 인증을 처리하는 데에 특화되어 있는 데에 반해, JWT와 같은 다른 인증 방식을 사용하는 경우에는 이를 처리하기 위한 별도의 필터가 필요하다.
  2. 인증 방식의 분리: JWT와 같은 인증 방식은 일반적으로 사용자 이름과 비밀번호를 이용한 폼 기반 인증과는 다른 방식으로, 이러한 인증 방식은 별도의 필터로 분리하여 관리해야 코드의 구조가 명확해진다.
  3. 코드의 모듈화와 재사용성: 별도의 필터를 만들어 코드를 모듈화하면 다른 프로젝트에서도 동일한 인증 방식을 사용할 때 이를 활용하여 재사용성을 높일 수 있다.
  4. 보안 강화: 일반적으로 토큰 기반 인증 방식은 추가적인 보안 검증이 요구됨에 따라 별도의 필터가 필요하다.

코드를 통해 Custom 필터의 예시를 확인해보자면, UsernamePasswordAuthenticationFilter를 사용하기보다는 추상 클래스인 GenericFilterBean의 상속을 통해 다른 Custom 필터를 만들어 인증 요청을 처리할 수 있다.

public class CustomAuthenticationFilter extends GenericFilterBean {

    private final TokenProvider tokenProvider;

    public CustomAuthenticationFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String token = resolveToken(request);
        String requestURI = request.getRequestURI();

        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);           
        }
        filterChain.doFilter(request, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        ...
    }
}

토큰 역시 기본 유형(username/password)이 아닌 다른 유형의 토큰을 사용하려고 하면 Custom으로 만들고서 이로부터 사용자 인증 정보를 추출하여 UsernamePasswordAuthenticationToken을 반환하면 된다.

그리고 위 코드의 TokenProvider가 이 역할을 한다고 하면, SecurityConfigurer를 구현한 SecurityConfigurerAdapter를 상속받아 Custom Security Config를 생성하여 CustomAuthenticationFilter와 함께 TokenProvider를 의존성 주입 후 UsernamePasswordAuthenticationFilter 앞에 이 필터를 추가해주면 된다.

public class CustomSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;

    public CustomSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        CustomSecurityConfig customFilter = new CustomSecurityConfig(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

이렇게 JWT와 같은 특정 인증 방식을 사용할 경우에 적절한 Custom 필터 및 모듈을 만들 수 있음을 알아보았다.

참고자료


profile
유랑하는 백엔드 개발자 새싹 블로그

0개의 댓글