본문 바로가기

스프링

스프링 JWT(3): JWTFilter

이번에는 발급받은 JWT를 검증하는 필터인 JWTFilter와 리프레시 토큰을 이용한 토큰 갱신을 구현해보자.

저번 글에서 구현한 JWTUtil을 이용해 토큰을 검증할 것이다.

 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException
    {
        String auth = request.getHeader("Authorization");

        //토큰이 없는경우
        if (auth == null || !auth.startsWith("Bearer "))
        {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //토큰없는경우 401반환
            response.getWriter().write("Token does not exist");
            return;
        }
        String token = auth.split(" ")[1]; //Bearer 부분 제거

 

우선, 요청 헤더에서 토큰을 얻은 뒤, 토큰이 없거나, 'Bearer '로 시작하지 않는 경우, 즉시 401을 반환한다.

토큰이 있다면, Bearer부분을 제거한 토큰을 얻는다.

 

try
        {
            //만료된 경우
            if (jwtUtil.isExpired(token)) //만료시 예외를 던짐
            {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //만료된 경우 401반환
                response.getWriter().write("Token expired");
                return;
            }
            //토큰에서 id role가져옴
            String username = jwtUtil.getUsernameFromToken(token);

            List<GrantedAuthority> authorities = new ArrayList<>();

            if (username.equals("admin"))
            {
                authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
            } else
            {
                authorities.add(new SimpleGrantedAuthority(Role.USER.getValue()));
            }

            User user = new User(username, "temp", authorities);

            //시큐리티 인증토큰
            Authentication authToekn = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());

            //서버에 사용자등록(요청기간 동안에만 유효)
            SecurityContextHolder.getContext().setAuthentication(authToekn);

            filterChain.doFilter(request, response);

 

이후, 토큰의 만료여부를 확인해준다. 다만 주의할 점이, 토큰이 만료된 경우,

ExpiredJwtException

을 발생시키기에 이에 대한 예외처리가 필요하게 되고, 조건문 내부는 어차피 실행되지 않게 된다. 이에 대한 부분은 추후 개선할 필요가 있을 것 같다.

 

이후에 사용자 이름을 얻고, Authentication을 생성해 UsernamePasswordAuthenticationToken를 등록해준다.

이는 요청이 진행되는 동안에만 유효함!

 

    }catch (ExpiredJwtException e) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //만료된 경우 401반환
        response.getWriter().write("Token expired");
    }catch(JwtException e){
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Invalid token");
    }catch (IllegalArgumentException e) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Invalid token");
    }
}

 

이후 예외처리로 마무리해준다.

 

이제 이 필터를 SecurityConfig에서 LoginFilter 이전에 등록하면 된다.

http
        .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

 

JWTFilter의 위치는 인증이 필요한 다른 필터들 보다 앞쪽에 위치해야하기에 로그인 필터, 즉UsernamePasswordAuthenticationFilter 앞쪽에 위치시켜주면 된다.

 

그런데

이렇게 배치를 하고 테스트를 해보면, 로그인 요청시 문제가 발생한다. JWTFilter를 로그인 필터 앞쪽에 배치시켰기에 로그인 요청시 토큰이 없어 요청을 거부하기때문인데 이를 해결하기 위해 JWTFilter를 로그인 필터 뒤로 배치하거나, 

다음 코드처럼

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
    String path = request.getRequestURI();
    AntPathMatcher pathMatcher = new AntPathMatcher();

    // 필터를 거치지 않을 경로 리스트
    List<String> excludePaths = List.of(
            "/login",
            "/",
            "/api/register"
    );

    // 경로가 하나라도 매칭되면 필터를 거치지 않음
    return excludePaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
}

리스트에 있는 경로는 필터없이 다음 필터로 넘기는 shouldNotFilter를 오버라이드해 구현해주면 된다.

/login 외에도 인증이 필요없는 경로에 대해서도 리스트에 등록해주었다.

 

JWTFilter

package com.example.jwt_practice.security;

import com.example.jwt_practice.user.domain.Role;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException
    {
        String auth = request.getHeader("Authorization");

        //토큰이 없는경우
        if (auth == null || !auth.startsWith("Bearer "))
        {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //토큰없는경우 401반환
            response.getWriter().write("Token does not exist");
            return;
        }

        String token = auth.split(" ")[1]; //Bearer 부분 제거

        try
        {
            //만료된 경우
            if (jwtUtil.isExpired(token)) //만료시 예외를 던짐
            {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //만료된 경우 401반환
                response.getWriter().write("Token expired");
                return;
            }
            //토큰에서 id role가져옴
            String username = jwtUtil.getUsernameFromToken(token);

            List<GrantedAuthority> authorities = new ArrayList<>();

            if (username.equals("admin"))
            {
                authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
            } else
            {
                authorities.add(new SimpleGrantedAuthority(Role.USER.getValue()));
            }

            User user = new User(username, "temp", authorities);

            //시큐리티 인증토큰
            Authentication authToekn = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());

            //서버에 사용자등록(요청기간 동안에만 유효)
            SecurityContextHolder.getContext().setAuthentication(authToekn);

            filterChain.doFilter(request, response);


        }catch (ExpiredJwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //만료된 경우 401반환
            response.getWriter().write("Token expired");
        }catch(JwtException e){
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
        }catch (IllegalArgumentException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
        }
    }


    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String path = request.getRequestURI();
        AntPathMatcher pathMatcher = new AntPathMatcher();

        // 필터를 거치지 않을 경로 리스트
        List<String> excludePaths = List.of(
                "/login",
                "/",
                "/api/register",
                "/api/refresh-token"
        );

        // 경로가 하나라도 매칭되면 필터를 거치지 않음
        return excludePaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
    }
}

 

리프레시 토큰을 이용한 액세스 토큰 갱신

@PostMapping("/api/refresh-token")
public ResponseEntity<String> refresh(HttpServletRequest request, HttpServletResponse response)
{
    Cookie[] cookies = request.getCookies();
    String refreshToken = Arrays.stream(cookies)
            .filter(cookie -> "refreshToken".equals(cookie.getName()))
            .findFirst()
            .map(Cookie::getValue)
            .orElse(null);

    if(refreshToken == null) //없거나, 유효하지 않다면, 401줘서 다시 로그인 할 수 있도록.
    {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("refresh token does not exist");
    }
    try{
        jwtUtil.isExpired(refreshToken);
    }catch (ExpiredJwtException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("expired refresh token");
    }catch(JwtException | IllegalArgumentException e){
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
    }

    // 새 액세스 토큰 발급
    String username = jwtUtil.getUsernameFromToken(refreshToken);
    String newAccessToken = jwtUtil.generateJwt(username, 1000 * 60 * 10L);//10분

    // 응답에 새 액세스 토큰 추가
    response.addHeader("Authorization", "Bearer " + newAccessToken);
    return ResponseEntity.ok("Token refreshed");
}

 

나중에 리프레시 토큰의 유효기간을 고려해 새로운 리프레시 토큰을 발급해주는 로직도 추가해보면 좋을 것 같다.

또, JWTFilter에서 클라이언트측의 JWT를 신뢰하여 username을 그대로 사용했지만, 보안을 고려한다면, 추가적인 검증을 하는 것도 좋을 것 같다.

 

 

 

 

나중에 JWT블랙리스트와 로그아웃 처리도 다뤄야겠다.