Spring Security에 JWT를 잘 녹여보자

5

API 서버들의 보안은 매우 중요합니다. 하지만 관련 자료를 디깅해보면 대부분 유사한 메커니즘으로 작성되어 있었는데요.

시큐리티를 적극적으로 활용하는 방법을 고민한 흔적들은 찾지 못했던 것 같아요.

아마, 개발하는 초기에 잠깐 적용하기 위해 찾아보고, 구현이 되면 끝까지 손댈 경우가 거의 없기 때문에 많은 관심을 갖기엔 어려운 기술이라 그런 것 같습니다. 저 또한 그렇게 살고 있으니까요.

나름 시큐리티를 공부했다고 우쭐대고 있었는데, 제 입맛대로 바꿔보려고 하니 턱턱 막히게 되어 다시 한번 들여다보는 시간을 가졌습니다.

대부분 JWT 인증 방식의 구성인 경우 OncePerRequestFilter를 상속 받아 사용자 정의 필터를 작성하고,
UsernamePasswordAuthenticationFilter 앞에 사용자 정의 필터를 위치 시킵니다.

public class CustomFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(CustomFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
        	...
            ...
            
            Authentication authentication = new UsernamePasswordAuthenticationToken(pricipal, null, authorities)
            SecurityContextHolder.getContext().setAuthentication(authentication)

            filterChain.doFilter(request, response);
        } catch (AuthenticationException authException) {
            log.trace("AuthenticationException: {}", authException.getMessage());
        	SecurityContextHolder.clearContext();
            response.setStatus(401);
            response.getWriter().write("Unauthorized")
        }
    }
}

public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	// 다양한 방식으로 사용자 정의 필터를 위치 시킬 수 있다.
        // httpSecurity.addFilterAfter(new CustomFilter(), LogoutFilter.class)
        // httpSecurity.addFilterBefore(new CustomFilter(), UsernamePasswordAuthenticationFilter.class)
		// httpSecurity.addFilterAt(new CustomFilter(), UsernamePasswordAuthenticationFilter.class)
		// http.apply(new CustomSecurityConfigurer(new CustomFilter()));
        return http.build();
    }
}

왜 그럴까요?

인증을 담당하는 추상 클래스의 concrete class가 바로 UsernamePasswordAuthenticationFilter 때문입니다.

AbstractAuthenticationProcessingFilter의 추상 메서드인 attemptAuthentication를 오버라이딩하여 인증을 시도하게 됩니다.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    ...
    
	@Override
	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());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

    ...
}

다들 알고 계셨나요? 기본적으로 DaoAuthenticationProvider가 AuthenticationManager에 등록된다는 사실을요

이제 저의 생각을 말해보겠습니다.

토큰 인증 기반 서버는 상태가 없습니다. 이미 토큰을 발급 받은 사람은 인증이 된 사용자일 것입니다.

다시 CustomFilter의 내부 코드를 봤을 때

Authentication authentication = new UsernamePasswordAuthenticationToken(pricipal, null, authorities)
SecurityContextHolder.getContext().setAuthentication(authentication)

위의 방식으로 코드를 작성하게 되면 이 사용자는 인증이 된 유저로 판단됩니다.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	/**
	 * This constructor can be safely used by any code that wishes to create a
	 * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
	 * will return <code>false</code>.
	 *
	 */
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null);
		this.principal = principal;
		this.credentials = credentials;
		setAuthenticated(false);
	}

	/**
	 * This constructor should only be used by <code>AuthenticationManager</code> or
	 * <code>AuthenticationProvider</code> implementations that are satisfied with
	 * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
	 * authentication token.
	 * @param principal
	 * @param credentials
	 * @param authorities
	 */
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true); // must use super, as we override
	}

    ...
}

생성자 두개를 제공하고 있는데, 파라미터 리스트가 3개인 생성자는 authenticated가 true 상태이기 때문입니다.
정적 메서드로 unauthenticated, authenticated 도 제공하며 내부에는 위의 생성자를 감싸놨습니다.

CustomFilter를 수행한 다음 UsernamePasswordAuthenticationFilter가 수행되는데,

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
        
    ...    

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);
			return;
		}
		try {
			Authentication authenticationResult = attemptAuthentication(request, response);
			if (authenticationResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				return;
			}
			this.sessionStrategy.onAuthentication(authenticationResult, request, response);
			// Authentication success
			if (this.continueChainBeforeSuccessfulAuthentication) {
				chain.doFilter(request, response);
			}
			successfulAuthentication(request, response, chain, authenticationResult);
		}
		catch (InternalAuthenticationServiceException failed) {
			this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
			unsuccessfulAuthentication(request, response, failed);
		}
		catch (AuthenticationException ex) {
			// Authentication failed
			unsuccessfulAuthentication(request, response, ex);
		}
	}
    	protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
		if (this.requiresAuthenticationRequestMatcher.matches(request)) {
			return true;
		}
		if (this.logger.isTraceEnabled()) {
			this.logger
				.trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher));
		}
		return false;
	}
    ...
    
}

requiresAuthentication 분기 라인에 걸리게 되면 다음 필터체인으로 이동하고 끝이 납니다.

즉, UsernamePasswordAuthenticationFilter가 attemptAuthentication의 메서드 블록 내부의 인증 로직을 돌릴 수 없습니다.
바로 다음 필터로 던지기 때문에 UsernamePasswordAuthenticationToken을 처리할 프로바이더는 실행되지 않습니다.

어쩌다가 UsernamePasswordAutehtncationToken을 사용하는 코드가 즐비하게 되었을까요???

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 = SPRING_SECURITY_FORM_USERNAME_KEY;

	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
	}

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

	@Override
	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());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
    ...
}

구현하기 나름이겠지만 대부분 REST API에 토큰 발급을 요청하는 프로세스를 통해 유저 식별 정보를 담아 토큰 발급을 담당하는 ENDPOINT로 요청합니다.

JWT 토큰을 통한 Security의 인증 과정을 이해해보면 UsernamePasswordAuthenticationFilter는 동작하지 않습니다.

토큰을 발급 받는 과정에서도 출입증(토큰)을 발급하는게 주된 관심사이고 목적이자 책임이라 생각하는데요.

다르게 말하면 UsernamePasswordAuthenticationFilter는 쓸모가 없다는 생각을 하게 되었습니다.

UsernamePasswordAuthenticationToken을 처리하는 프로바이더는 DaoAuthenticationProvider 인데요.

필터체인에서 동작도 안하고, 동작을 유도 해도 JWT 토큰에는 유저에 대한 패스워드가 없습니다.

그럼 앞단에서 토큰 정보를 통해 유저 식별정보 principal, credentials 값을 얻어야하는데,

얻어서 또 다시 프로바이더에서 그 값을 통해 DaoAuthenticationProvider를 실행시켜 또 다시 유저정보를 가져오는 행위를 해야합니다.
사용되지도 않는데 DI하기 위해서 UserDetailsService.loadUserByUsername를 구현할 필요가 있나 싶습니다.
전 그래서 DaoAuthenticationProvider를 제거하고, UserDetailsService도 구현하지 않는 방식을 가져가봤습니다.

스프링 시큐리티의 기본 페러다임을 헤치지 않고 잘 녹여보고자 했습니다.

토큰 발급 서비스로 대체해버리기

AuthenticationService를 직접 구현해서 우리 사용자가 맞으면 토큰을 발급하는 시큐리티랑 관련 없는 서비스를 만들어서 대체해도 될 것 같습니다.

토큰을 발급하는거 조차 SecurityContext와는 전혀 관계가 없습니다. 애초에 그 endpoint는 열려있거든요 (조금 더 디테일하게 잡을 수도 있겠지만요)

하지만 이건 시큐리티의 내부동작을 활용하지 않고 입맛대로 구현해서 상태 처리, success, failure에 대한 처리를 직접하기 때문에
페러다임을 헤친 것이나 다름 없습니다.

PreAuthenticatedAuthenticationProvider 를 사용하기

"PreAuthenticated"는 사용자가 이미 인증을 받았을 때의 상태를 의미합니다. 즉, 사용자는 이미 시스템에 로그인했으며, 이후 추가 인증 과정 없이 서비스를 이용할 수 있는 상태를 말합니다.

JWT를 이미 사전에 인증된 사용자라 생각하고,

Redis와 같은 Repository에서 토큰에 대한 UserDetails 정보를 가져오는 방식을 취해볼 수도 있을 것 같습니다.

토큰을 통해 인증을 처리하는 단계로 볼 건지, 토큰이 발급 되었다는 것을 이미 인증을 받은 후의 상태를 의미로 해석할 건지에 대해 생각 해봐야 할 것 같습니다.

(access token이라 생각한다면 적용해도 무방할 것 같습니다)

UsernamePasswordAuthenticationToken을 통한 처리보다는 나은 것 같습니다.

public interface AuthenticationUserDetailsService<T extends Authentication> {

	/**
	 * @param token The pre-authenticated authentication token
	 * @return UserDetails for the given authentication token, never null.
	 * @throws UsernameNotFoundException if no user details can be found for the given
	 * authentication token
	 */
	UserDetails loadUserDetails(T token) throws UsernameNotFoundException;

}

토큰에 대한 정보를 가지고 loadUserDetails 함수를 통해 특정 저장소에 있는 User 정보를 가져온다는 점에서 봤을때,적절한 처리를 도와줄 것을 예상해볼 수 있습니다.
이 구현은 Redis가 되었건, File이 되었건, RDB가 되었건 토큰에 맞는 유저정보를 뱉어주면 될 것 같습니다
이 방식은 아직 해보지 않아서 추 후에 기술 하도록 하겠습니다.

UserDetails 객체를 반환한다는 것은, 특별한 ArgumentResolver를 커스텀하지 않아도 @AuthenticationPrincipal 애너테이션을 사용해서 유저정보를 컨트롤러에서 손쉽게 받아볼 수 있거나, 예외 처리에 대한 로직에만 집중할 수 있다는 뜻이기도 합니다.

필터 단의 에러를 시큐리티가 처리할 수 있도록 변경하기

CustomFilter에서 발생하는 예외는 서블릿 필터 단의 예외이기 때문에, 이 예외는 ExceptionTranslationFilter 에서 처리해주지 못합니다.

public interface AuthenticationEntryPoint {
    void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

AuthenticationEntryPoint는 AuthenticationException, AccessDeniedException 두 예외에 대한 전역처리를 도와주는 인터페이스입니다.

public class CustomFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    public CustomFilter(CustomAuthenticationEntryPoint authenticationEntryPoint) {
        this.authenticationEntryPoint = authenticationEntryPoint;
    }


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
        	...
            ...
            
            Authentication authentication = new UsernamePasswordAuthenticationToken(pricipal, null, authorities)
            SecurityContextHolder.getContext().setAuthentication(authentication)

            filterChain.doFilter(request, response);
        } catch (AuthenticationException authException) {
            log.trace("AuthenticationException: {}", authException.getMessage());
        	SecurityContextHolder.clearContext();
        	authenticationEntryPoint.commence(request, response, authException);
        }
    }
}

public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.exceptionHandling(exceptionHandling -> exceptionHandling
                .authenticationEntryPoint(customAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())

        );
        http.apply(new CustomSecurityConfigurer(new CustomFilter(customAuthenticationEntryPoint())));
        return http.build();
    }

	//  AuthenticationConfiguration 에서 생성되는 AuthenticationManager를 사용하지 않는다.
	//  기본 구성에서는 AuthenticationManager를 초기화가 되지 않은 상태에서는 lazyBean으로 프록시 객체를 뱉어주는데
    //  프록시 객체이기 때문에 아래의 설정은 에러가 난다.
	//  AuthenticationManager를 초기화 할 때 UserDetailsService의 구현체가 필요하다.
//	@Bean
//	public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
//		ProviderManager providerManager = (ProviderManager) authenticationConfiguration.getAuthenticationManager();
//		providerManager.getProviders().removeAll(providerManager.getProviders());
//		providerManager.getProviders().add(new CustomProvider());
//		return providerManager;
//	}

	// 직접 핸들링
	@Bean
    public AuthenticationManager authenticationManager() {
    	CustomProvider customProvider = new CustomProvider();

        return new ProviderManager(customProvider);
    }

    @Bean
    private CustomAuthenticationEntryPoint customAuthenticationEntryPoint() {
        return new CustomAuthenticationEntryPoint();
    }
}

외부에서 발생한 에러도 하나의 EntryPoint에서 관리 할 수 있게끔 유도했습니다.

나중에 하나씩 더 추가하겠습니다.

글을 쓰다보니 JWT와 관련된 내용은 하나도 없었네요... 제목을 바꿔야하나 싶은데,
나중에 JwtAuthenticationProvider로 갈아끼운 깃헙 링크를 남기도록 하겠습니다.

오늘은 20000

헛소리라 생각드신다면 따끔한 회초리 부탁드립니다 :)

0개의 댓글