개발일지: 필터

동준·2024년 6월 24일
0

개발일지

목록 보기
4/7
post-thumbnail

스프링 시큐리티

한창 스터디와 포폴 개발을 병행하면서 이전에 익혔던 개념을 천천히 복습하는 과정이었다. 예전에 썼던 코드를 단순히 복사하고 붙여넣는 수준에 그치는 것이 아닌, 어떤 원리이고 어떻게 응용할 수 있을 지에 대한 고민을 거듭하며 코드를 설계하는 것에 주안점을 잡았다.

초기에 인증 방식을 고민하면서 컨트롤러단이 아닌 필터단에서의 처리가 조금 더 보안의 취지에 맞다는 나름의 결론을 내렸었는데, 이번에 코드를 설계하면서 겪은 트러블 슈팅과 그로 인해 얻은 개념적인 결론들을 정리할 예정.

1) requestMatchers() 메소드

스프링 시큐리티 관련 설정을 지정하는 클래스(보통 WebSecurityConfig 등의 명칭을 부여한다)에서 필터단의 설정을 부여한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/blog/**").permitAll()
                .anyRequest().authenticated()
            )
// ...

SecurityFilterChain 타입의 반환값을 지니고 HttpSecurity 타입 파라미터를 받는 메소드를 빈으로 등록한다. 여기서 많이 마주하게 될 requestMatchers() 메소드는 필터단에서의 인증 예외를 부여하게 된다.

예를 들어, 회원가입은 당연히 인증 수단이 없는 사용자가 호출하는 API일 테니 이 부분에 대하여는 인증에 대하여 예외를 처리하는 것이 옳다. 근데 그건 좀 매우 당연(...)한 내용이고, 내가 겪었던 이슈는 저 requestMatchers() 메소드와 관련된 이슈였다.

트러블슈팅1: requestMatchers() 메소드의 동작 원리

당시 문제가 생겼던 건 인증을 담당하는 JwtAuthorizationFilter 클래스에서였다. 참고로 현재는 아래의 클래스에서 업데이트가 이뤄진 상태다.

@Slf4j(topic = "JWT 검증 및 인가")
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService; // 사용자가 있는지 확인
    private final UserRepository userRepository;
    private final RedisTemplate<String, String> redisRefreshToken;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        /**
         1. 클라이언트는 엑세스토큰만 신경쓴다
         2. 어차피 레디스에 리프레쉬토큰이 저장되어있다
         3. 즉 엑세스토큰을 set-cookie로 설정하면 굳이 클라이언트 측에서 토큰 관련 로직을 건드릴 필요 없다
         */

        String accessTokenValue = jwtUtil.getAccessTokenFromRequestCookie(request); // -> 요 놈을 써야 해
        log.info("쿠키로부터 갖고 온 엑세스토큰: " + accessTokenValue);
        String accessToken = jwtUtil.substringToken(accessTokenValue);
        
// ...

// JwtUtil 클래스에 존재하는 쿠키 추출값 subString 메소드
public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
    	return tokenValue.substring(7);
    }

    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
}

문제가 생겼던 것은 저 substringToken() 메소드였다. 당시 상황을 정리하자면...

requestMatchers().permitAll() 설정 여부에 따른 상황

(1) 설정 없이 인증 절차

  • 멀쩡히 잘 돌아감

(2) 설정으로 인증 배제

  • 예외 발생(?????????)

회원가입 API를 호출할 때, (2)번의 상황이 발생해서 처음에는 스프링 시큐리티 자체의 설정 문제인 줄 알았는데, 혹시 몰라서 로그인을 해서 인증이 필요한 API 요청을 하니까 (1)번의 상황도 같이 발생했다.

즉, 시큐리티 설정이 잘못된 것은 아니었으나 당시 나는 requestMatchers() 메소드에 엔드포인트를 파라미터로 넣고 permitALl() 메소드를 호출하면 필터를 아예 우회하는 것으로 착각하고 있었다.

위의 추출값 문자열 슬라이스 메소드에서 발생한 문제는, 인증(즉, 쿠키로부터 토큰 포함 문자열로부터 정보 추출)을 위한 값 메소드가 null이면 당연히 예외를 일으키면서 필터단에서 막히게 되는 것이다. 정리하자면, 인증 배제 요청이어도 필터를 거치며, 대신 UsernamePasswordAuthenticationFilter와 같은 인증 필터(혹은 그를 상속해서 커스터마이징 된 필터)가 permitAll() 설정이 적용된 요청을 통과시키는 것이다.

2) 커스텀 필터

인증 역할을 필터단에서 처리하는 만큼, 인증의 예외에 대해서도 필터단에서 처리하는 게 맞다는 생각이 들었다. 인증 필터와 인가 필터 역시 OncePerRequestFilter 클래스를 상속해서 JWT 인증에 맞춰 커스터마이징한 만큼, 예외 처리 필터 역시 OncePerRequestFilter를 상속해서 구현할 수 있었다.

고려할 점은 두 가지였다

1. 인증 과정에서 '만료'는 인증의 키워드

2. 필터의 순서 정리

1번 케이스는 인증 과정에서 우선 요청 쿠키의 엑세스토큰을 추출함과 동시에 해당 내용에 담긴 이메일 정보를 파싱해야 했다. 이 과정에서 만료된 엑세스토큰이 전달될 수도 있는데, 토큰은 기본적으로 만료된 이상 서명 해석 이전에 예외 처리 결과로 반환시킨다. 그래서, 토큰 만료 관련 예외인 ExpiredJwtException은 예외 처리 필터의 대상에서 제외시켜야 한다.

public Date getTokenIat(String token) {
    try {
        // 만료된 토큰에서 클레임을 파싱하되 서명 검증은 생략
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getIssuedAt(); // username이나 email을 subject로 저장했다고 가정
    } catch (ExpiredJwtException e) {
        // 토큰이 만료되었을 경우 ExpiredJwtException에서 클레임을 추출
        return e.getClaims().getIssuedAt();
    }
}

// ...

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
    try {
        chain.doFilter(req, res); // go to 'JwtAuthenticationFilter'
    }  catch (SecurityException | MalformedJwtException | SignatureException | UnsupportedJwtException | IllegalArgumentException ex) {
        setErrorResponse(HttpStatus.UNAUTHORIZED, res, ex);
    }
}

2번 케이스는 어느 시점에서 JWT로부터 발생한 예외를 검증 및 처리할 것인지의 순서 문제였다. 토큰의 유효성 검증 처리이기 때문에, 우선 토큰이 유효한지부터 검증 후, 만료됐는지에 따라서 토큰 재발급 로직을 처리한다. 즉, 유효성 판별이 먼저 수행되고 인증 처리 과정에서 토큰의 만료를 검증해서 재발급 여부를 결정해야 한다.

http.addFilterBefore(jwtAuthorizationFilter, JwtAuthenticationFilter.class); // 인가 처리 필터
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 인증(+ 로그인) 처리 필터
http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); // JwtAuthenticationFilter 앞단에 JwtExceptionFilter를 위치시키겠다는 설정
profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글