
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는 여러 모듈을 통해 사용자 인증 정보를 처리하게 되는데, 이번에는 그 모듈들에 대해서도 알아보도록 하자.
AuthenticationFilterAuthenticationFilter는 Spring Security에서 사용자의 인증을 처리하는 데 사용되는 기본적인 필터로, 이 필터는 실제로 인증을 수행하지는 않지만, 사용자의 인증 요청을 가로채어 AuthenticationManager에 전달한다. 실제 인증 메커니즘은 AuthenticationManager와 이에 연결된 AuthenticationProvider에서 수행되는데, AuthenticationFilter는 사용자가 인증되었는지 여부를 확인하고, 인증되지 않은 경우 인증을 위임하는 데에 주요 목적을 갖는다.
UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken은 Authentication을 구현한 AbstractAuthenticationToken의 하위 클래스로, 사용자 정보의 username이 principal 역할을 하고, password가 credentials 역할을 한다. 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를 확장한 것으로, 이 필터는 사용자가 인증을 시도할 때 사용자의 username과 password를 수집하고, 이를 기반으로 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;
}
AuthenticationProviderAuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데, 인증 전의 Authentication 객체를 받아서 주어진 인증 유형에 따라 인증 후 인증이 완료된 객체를 반환하는 역할을 한다. 아래와 같은 인터페이스를 구현해 Custom AuthenticationProvider를 작성하고 AuthenticationManager에 등록하면 된다.
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
ProviderManager
AuthenticationManager를 구현한 ProviderManager는 AuthenticationProvider를 구성하는 목록을 갖는다.
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);
...
}
UserDetailsServiceUserDetailsService는 UserDetails 객체를 반환하는 하나의 메소드만을 갖고 있는데, 일반적으로 이를 구현한 클래스에 UserRepository를 주입받아 DB와 연결하여 처리한다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetailsSpring 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();
}
SecurityContextHolderSecurityContextHolder는 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다. SecurityContextHolder를 통해 현재 사용자의 SecurityContext를 검색할 수 있다.
SecurityContextAuthentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication을 저장하거나 꺼내올 수 있다.
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
GrantedAuthorityGrantedAuthority는 현재 사용자(Principal)가 가지고 있는 권한을 의미하며, ROLE_USER나 ROLE_ADMIN 같이 ROLE_*의 형태로 사용한다. GrantedAuthority 객체는 UserDetailsService에 의해 불러올 수 있고, 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 결정한다.
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
지금까지 Spring Security의 기본 모듈들에 대해 알아봤다. 그런데 많은 경우 Spring Security가 제공하는 기본 인증 유형이 아닌 경우에 다른 Custom 필터 및 모듈을 만들어 사용하게 된다. 그 이유는 다음과 같다.
UsernamePasswordAuthenticationFilter는 username과 password를 이용한 폼 기반 인증을 처리하는 데에 특화되어 있는 데에 반해, JWT와 같은 다른 인증 방식을 사용하는 경우에는 이를 처리하기 위한 별도의 필터가 필요하다.코드를 통해 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 필터 및 모듈을 만들 수 있음을 알아보았다.