토큰 검증 오류

wish17·2023년 5월 5일
0

오류정리

목록 보기
1/7

코드

수정전

// JWT 검증 필터 구현 클래스
// 클라이언트 측에서 전송된 request header에 포함된 JWT에 대해 검증 작업을 수행하는 코드
public class JwtVerificationFilter extends OncePerRequestFilter {  // OncePerRequestFilter 상속받아서 request 당 단 한 번만 수행
    private final JwtTokenizer jwtTokenizer; // JWT를 검증하고 Claims(토큰에 포함된 정보)를 얻는 데 사용
    private final CustomAuthorityUtils authorityUtils; // JWT 검증에 성공하면 Authentication 객체에 채울 사용자의 권한을 생성하는 데 사용

    public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request); // JWT 검증
            setAuthenticationToContext(claims);              // SecurityContext에 검증된 정보 저장
            filterChain.doFilter(request, response);         // 인증이 성공한 경우 다음 필터 호출
        } catch (SignatureException se) {
            sendErrorResponse(response, HttpStatus.valueOf(401), "The token information is incorrect."); // 토큰 정보가 잘못되었을 경우 401 응답 반환
        } catch (MalformedJwtException me){
            sendErrorResponse(response, HttpStatus.valueOf(401), "JWT strings must contain exactly 2 period characters"); // 토큰 정보가 잘못되었을 경우 401 응답 반환
        } catch (ExpiredJwtException ee) {
            sendErrorResponse(response, HttpStatus.valueOf(401), "The token has expired."); // JWT가 만료된 경우 401 응답 반환
        } catch (Exception e) {
//            sendErrorResponse(response, HttpStatus.valueOf(401), "The token information is incorrect.");
            request.setAttribute("exception", e); // 토큰의 길이가 짧으면 다른 오류를 발생시켜서 주석처리 함
            filterChain.doFilter(request, response);
        }
    }

    // 특정 조건에 부합하면(true이면) 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 해주는 메서드인 OncePerRequestFilter의 shouldNotFilter()를 오버라이드
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");

----------------------------------------문제점----------------------------------------
        return authorization == null || !authorization.startsWith("Bearer");  //  header의 값이 null이거나 Bearer로 시작하지 않는다면 해당 Filter(토큰 검증)의 동작을 수행하지 않도록 정의
        // JWT가 Authorization header에 포함되지 않았다면 JWT 자격증명이 필요하지 않은 리소스에 대한 요청이라고 판단하고 다음(Next) Filter로 처리를 넘기는 것
    }
----------------------------------------문제점----------------------------------------

    private Map<String, Object> verifyJws(HttpServletRequest request) { // JWT를 검증하는데 사용되는 메서드
        String jws = request.getHeader("Authorization").replace("Bearer ", ""); // "Bearer" 부분을 제거해서 JWT(accessToken) 얻기
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); // JWT 서명(Signature)을 검증하기 위한 Secret Key 얻기
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();   // Claims를 파싱 // 여기서 오류가 발생하면 SignatureException 발생

        return claims; // Claims가 정상적으로 파싱이 되면 서명 검증에 성공한거다.
    }

    private void setAuthenticationToContext(Map<String, Object> claims) { //  Authentication 객체를 SecurityContext에 저장하기 위한 메서드
        String username = (String) claims.get("email");   // 파싱한 Claims에서 username 얻기
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));  //  Claims에서 권한 정보 얻기
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);  // 이미 앞에서 인증된 Authentication객체를 생성하는데 비밀번호는 불필요하니까 null입력
        SecurityContextHolder.getContext().setAuthentication(authentication); //  SecurityContext에 Authentication 객체를 저장
    }

}

위와 같이 shouldNotFilter()메서드에 !authorization.startsWith("Bearer")가 있으면 토큰 헤더를 없애버리고 payload와 signature만 멀쩡하다면 유효한 토큰으로 인식된다.

ex) Authorization = eyJhbGciOiJIUzI1NiJ9.생략.생략가 통과된다.

하지만 Authorization = Bearer eyJhbGciOiJIUzI1NiJ9.생략.생략이 올바른 토큰값이다.


수정후

// JWT 검증 필터 구현 클래스
// 클라이언트 측에서 전송된 request header에 포함된 JWT에 대해 검증 작업을 수행하는 코드
public class JwtVerificationFilter extends OncePerRequestFilter {  // OncePerRequestFilter 상속받아서 request 당 단 한 번만 수행
    private final JwtTokenizer jwtTokenizer; // JWT를 검증하고 Claims(토큰에 포함된 정보)를 얻는 데 사용
    private final CustomAuthorityUtils authorityUtils; // JWT 검증에 성공하면 Authentication 객체에 채울 사용자의 권한을 생성하는 데 사용

    public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request); // JWT 검증
            setAuthenticationToContext(claims);              // SecurityContext에 검증된 정보 저장
            filterChain.doFilter(request, response);         // 인증이 성공한 경우 다음 필터 호출
        } catch (SignatureException se) {
            sendErrorResponse(response, HttpStatus.valueOf(401), "The token information is incorrect."); // 토큰 정보가 잘못되었을 경우 401 응답 반환
        } catch (MalformedJwtException me){
            sendErrorResponse(response, HttpStatus.valueOf(401), "JWT strings must contain exactly 2 period characters"); // 토큰 정보가 잘못되었을 경우 401 응답 반환
        } catch (ExpiredJwtException ee) {
            sendErrorResponse(response, HttpStatus.valueOf(401), "The token has expired."); // JWT가 만료된 경우 401 응답 반환
        } catch (Exception e) {
//            sendErrorResponse(response, HttpStatus.valueOf(401), "The token information is incorrect.");
            request.setAttribute("exception", e); // 토큰의 길이가 짧으면 다른 오류를 발생시켜서 주석처리 함
            filterChain.doFilter(request, response);
        }
    }
    // 특정 조건에 부합하면(true이면) 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 해주는 메서드인 OncePerRequestFilter의 shouldNotFilter()를 오버라이드
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");

----------------------------------------수정된 부분----------------------------------------
        return authorization == null;  //  header의 값이 null이면 해당 Filter(토큰 검증)의 동작을 수행하지 않도록 정의
        // JWT가 Authorization header에 포함되지 않았다면 JWT 자격증명이 필요하지 않은 리소스에 대한 요청이라고 판단하고 다음(Next) Filter로 처리를 넘기는 것
    }

    private Map<String, Object> verifyJws(HttpServletRequest request) { // JWT를 검증하는데 사용되는 메서드
        if(!request.getHeader("Authorization").startsWith("Bearer ")) {
            throw new SignatureException("");
        }
----------------------------------------수정된 부분----------------------------------------
        String jws = request.getHeader("Authorization").replace("Bearer ", ""); // "Bearer" 부분을 제거해서 JWT(accessToken) 얻기
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); // JWT 서명(Signature)을 검증하기 위한 Secret Key 얻기
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();   // Claims를 파싱 // 여기서 오류가 발생하면 SignatureException 발생

        return claims; // Claims가 정상적으로 파싱이 되면 서명 검증에 성공한거다.
    }

    private void setAuthenticationToContext(Map<String, Object> claims) { //  Authentication 객체를 SecurityContext에 저장하기 위한 메서드
        String username = (String) claims.get("email");   // 파싱한 Claims에서 username 얻기
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));  //  Claims에서 권한 정보 얻기
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);  // 이미 앞에서 인증된 Authentication객체를 생성하는데 비밀번호는 불필요하니까 null입력
        SecurityContextHolder.getContext().setAuthentication(authentication); //  SecurityContext에 Authentication 객체를 저장
    }

}

처음에 shouldNotFilter() 메서드에 바로 예외처리를 하려다 메서드의 역할을 생각했을 때 적합하지 않다고 생각되어 조금 고민했었는데, 그냥 토큰값이 입력되면 무조건 검사하게 바꾸고(|| !authorization.startsWith("Bearer")제거) verifyJws()메서드(토큰 검증하는 역할의 메서드)에서 예외처리를 적용해 문제점을 해결했다.


수정전도 정상적인 상태였다!

수정전 코드도 SecurityContext에 Authentication을 저장하지 않는다. 따라서 아래 첨부한 것과 같이 SecurityContext에서 Authentication을 가저와 본인인지 확인하는 방식인 delte메서드가 사용되면 오류가 발생한다.

(tmi. 이전에는 서비스 계층에서 토큰을 다시 검증하는 방식의 메서드로 테스트했었다.)


하지만 인증정보가 호출되는 위치에서 오류가 발생한다. 토큰이 잘못되어 발생하는 문제이기 때문에 토큰검증 필터에서 일괄적으로 예외처리를 해주는 것이 더 좋아 보인다.


이번 오류에서 배운점 정리

1. Service 계층에서 사용자 검증은 토큰이 아니라 Authentication을 이용하자.

Service 계층에서 request 헤더에 포함된 토큰을 다시 가져와 사용자 정보를 꺼내는 등의 방법은 security에서 이미 한번 거치는 토큰 검증과정을 중복으로 수행해야 한다.

코드의 중복을 줄이기 위해서 SecurityContext에서 Authentication을 가져와 사용하는게 좋을 것 같다.

2. 일괄적인 예외처리를 위해 null이 아닌이상 토큰검증은 스킵하지 말자.

위 코드들 중 수정전 상태에서도 정상적인 토큰상태가 아니라면 오류가 발생하기는 한다. 다만 오류의 위치가 AuthorizationFilter or 메서드단위가 될 것이다. (인증정보를 불러오는 위치)

따라서 다양한 위치에서 발생할 수 있는 문제가 되는데, 문제점은 토큰이 잘못되었다로 동일하니 가능한한 수정후코드와 같이 토큰검증 역할을 하는 필터에서 예외처리를 일괄적으로 해주는 것이 좋을 것 같다.

0개의 댓글