spring security 예외처리 - AuthenticationEntryPoint, 그리고 permitAll()

Jieun·2024년 7월 1일
0

프로젝트 기록

목록 보기
1/5

request - 인증, 예외처리 과정

1.request -> servlet filter 거침
2-1. filter를 통과한 요청은 Dispatch Servlet -> 일치하는Controller 단으로 전달 : 이후에 발생하는 Exception들은 @ControllerAdvice로 처리 가능

2-2. 하지만 filter에서 걸린 예외들은 DispatchServlet까지 전달되지 않기 때문에 @ControllerAdvice로 처리할 수 없다
: 따라서 인증과정에서 발생하는 예외를 처리하기 위한 AuthenticationEntryPoint를 구현하여 처리해야 함


- AuthenticationEntryPoint 구현체

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final static String EXCEPTION = "exception";
    private final static String LOGOUT = "logout";
    private final static String MALFORMED = "malformed";
    private final static String EXPIRED = "expired";
    private final static String HEADER = "header";
    private final static String BEARER = "bearer";

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String)request.getAttribute(EXCEPTION);
        response.setContentType("application/json; charset=UTF-8");

        if(exception!=null) {
            if (exception.equals(EXPIRED)) {
                setResponse(response,HttpStatus.UNAUTHORIZED.value(),"만료된 토큰입니다", 1000);
            } if (exception.equals(MALFORMED)) {
                setResponse(response,HttpStatus.BAD_REQUEST.value(), "잘못된 형식의 토큰입니다");
            } if(exception.equals(HEADER)) {
                setResponse(response,HttpStatus.BAD_REQUEST.value(), "Authorization 헤더가 존재하지 않습니다.");
            } if(exception.equals(LOGOUT)) {
                setResponse(response, HttpStatus.BAD_REQUEST.value(), "로그아웃 처리된 토큰입니다.");
            } if(exception.equals(BEARER)) {
                setResponse(response, HttpStatus.BAD_REQUEST.value(), "Bearer 형식이 잘못됐습니다.");
            }
        }
    }
    public void setResponse(HttpServletResponse response,int status,String msg) throws IOException{
        ObjectNode json = new ObjectMapper().createObjectNode();
        json.put("status",status);
        json.put("success", false);
        json.put("message", msg);
        String newResponse = new ObjectMapper().writeValueAsString(json);
        response.getWriter().write(newResponse);
        response.setStatus(status);
    }
    public void setResponse(HttpServletResponse response,int status,String msg, int code) throws IOException{
        ObjectNode json = new ObjectMapper().createObjectNode();
        json.put("status",status);
        json.put("success", false);
        json.put("message", msg);
        json.put("code", code);
        String newResponse = new ObjectMapper().writeValueAsString(json);
        response.getWriter().write(newResponse);
        response.setStatus(status);
    }

- 원하는 결과가 나오지 않았던 filter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final static String EXCEPTION = "exception";
    private final static String LOGOUT = "logout";
    private final static String MALFORMED = "malformed";
    private final static String EXPIRED = "expired";
    private final static String HEADER = "header";
    private final static String BEARER = "bearer";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            System.out.println("in auth filter");
            String token = resolveToken(request);
            //로그아웃 한 토큰인지 검증
            Optional<RefreshToken> refreshToken = refreshTokenRepository.findByToken(token);
            if(!refreshToken.isEmpty()) {
                if(refreshToken.get().getMemberId().equals("blackList")) {
                    //TODO:: 필터내부 오류는 ControllerAdvice로 처리 불가
                    // -> catch해서 AuthenticationEntryPoint : ServletResponse & ObjectMapper로 직접 예외처리
                    throw new LogOutToken();
                }
            }
            //유효한 토큰인지 검증
            if (jwtProvider.validateToken(token)) {
                Authentication authentication = jwtProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            //위치 문제
            System.out.println("done auth filter");
            filterChain.doFilter(request, response);
        } catch (LogOutToken e) {
            request.setAttribute(EXCEPTION,LOGOUT);
        } catch (MalformedJwtException e) {
            request.setAttribute(EXCEPTION,MALFORMED);
        } catch (ExpiredJwtException e) {
            request.setAttribute(EXCEPTION, EXPIRED);
        } catch (NoAuthorizationHeader e) {
            request.setAttribute(EXCEPTION, HEADER);
        } catch (NoBearer e) {
            request.setAttribute(EXCEPTION,BEARER);
        }

    }

    //헤더에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if(!StringUtils.hasText(bearerToken)) throw new NoAuthorizationHeader();
        if(!bearerToken.startsWith("Bearer")) throw new NoBearer();
        return bearerToken.substring(7);
    }
}

filter 내부에서 Exception이 발생하면 request의 exception이라는 필드에 오류 발생내역을 기록해서 넘기고,
이 exception을 전달받은 AuthenticationEntryPoint에서 오류 내용을 확인하고 직접 custom 한 예외처리를 하는 방식이다.

아직 프론트에서 어떻게 처리할 지 정확히 정해진 바가 없어서 일단 만료된 토큰의 경우에는 reissue 요청을 해야하므로 더 구분하기 편하게 response에 code라는 필드를 추가해봤다.


오류발생

하지만 테스트 해보려고 스웨거를 켜니까 화면이 나오지 않는 오류가 발생했다

ㅜㅜ

로그를 확인하니, in auth filter만 있고 done auth filter는 찍히지 않은 것으로 보아 filter 내부에서 발생한 exception 때문인 것으로 보인다


의문점

permitAll 안됨
근데 스웨거는 white_list에 등록해서 permitAll 해 둔 url인데..?
라는 의문이 생겨서 구글링 해본 결과 아주 도움되는 글을 찾았다

https://velog.io/@choidongkuen/Spring-Security-SecurityConfig-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%98-permitAll-%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8D%98-%EC%9D%B4%EC%9C%A0#jwtauthenticationentrypointclass

정리하자면 permitAll()은 필터체인을 아예 거치지 않게 해주는 것이 아니라
필터를 거치되, ExceptionTranslationFilter를 통한 예외처리 과정을 거치지 않고, 인증 객체 존재 여부 상관 없이 정상적으로 API를 호출할 수 있게 해준다는 것

이 점을 알고 살펴보니.. 순서에 문제가 있는 점이 보였다

- 고친 후의 filter

try {
            System.out.println("in auth filter");
            String token = resolveToken(request);
            //로그아웃 한 토큰인지 검증
            Optional<RefreshToken> refreshToken = refreshTokenRepository.findByToken(token);
            if(!refreshToken.isEmpty()) {
                if(refreshToken.get().getMemberId().equals("blackList")) {
                    //DONE:: 필터내부 오류는 ControllerAdvice로 처리 불가
                    // -> catch해서 AuthenticationEntryPoint : ServletResponse & ObjectMapper로 직접 예외처리
                    throw new LogOutToken();
                }
            }
            //유효한 토큰인지 검증
            if (jwtProvider.validateToken(token)) {
                Authentication authentication = jwtProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (LogOutToken e) {
            request.setAttribute(EXCEPTION,LOGOUT);
        } catch (MalformedJwtException e) {
            request.setAttribute(EXCEPTION,MALFORMED);
        } catch (ExpiredJwtException e) {
            request.setAttribute(EXCEPTION, EXPIRED);
        } catch (NoAuthorizationHeader e) {
            request.setAttribute(EXCEPTION, HEADER);
        } catch (NoBearer e) {
            request.setAttribute(EXCEPTION,BEARER);
        }
        //위치 변경
        System.out.println("done auth filter");
        filterChain.doFilter(request, response);

try문 내부에 doFilter(다음 필터로 넘기는 뿐)을 넣은 것이 문제였다.
permitAll 한 요청이어도 이 필터를 전부 거치기 때문에
Authentication 헤더가 없으므로 try의 마지막에 있던 doFilter에 도달하기 전에 exception이 발생하여 doFilter가 호출되지 못하고, 필터체인이 연쇄되지 않으므로 api 호출도 끝나버린 것이다.

따라서 doFilter를 try-catch 밖으로 빼버리는 것만으로 정상적으로 whiteList의 요청이 수행됐다.


또한 이것을 알게 되니 JwtProvider.validate()도 수정할 부분이 보였다

public boolean validateToken(String token) {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
        /* 기존의 코드
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            System.out.println("Invalid Token "+e);
        } catch (ExpiredJwtException e) {
            System.out.println("Expired Token "+e);
        } catch (UnsupportedJwtException e) {
            System.out.println("Unsupported Token "+e);
        } catch (IllegalArgumentException e) {
            System.out.println("JWT claims string is empty."+e);
        }
        return false;
         */
    }

기존에는 validate 안에서 exception을 catch 해서 그냥 로그만 찍어버리고 있었다..
이렇게 하면 filter로 exceptio이 전달이 안 되므로 원하는 방식으로 exception Response를 처리하지 못 한다

따라서 그냥 try를 빼서 Exception이 발생하게 두고, filter에서 알아서 처리하도록 하며 / 발생하지 않는다면 valid하므로 true를 반환하도록 수정했다.


parseClaim 수정

private Claims parseClaims(String accessToken) {
        //reissue 하는 경우에 토큰 관련 exception은 여기서 걸림
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        } catch (MalformedJwtException e) {
            throw new MalformedJwtException("잘못된 형식의 토큰입니다");
        }
    }

처음엔 filter에서 사용하는 validate & parseClaims(getAuthentication에서 사용)에서 try-catch를 전부 빼버렸었다.

하지만 그렇게 하니까 이 메소드를 호출하는 reissue에서 예외처리가 전혀 되지 않는다는 문제가 발생했다.
reissue는 filter단이 아니라 controller 내부이기 때문이다..

하지만 filter에서 validate - parseClaims의 순서를 생각해보니 parseClaim의 try-catch는 그대로 둬도 된다고 판단했다.
어차피 validate와 parseClaim의 기능이 비슷하므로 validate에서 exception이 전부 검증이 되기 때문에 validate를 통과하면 parseClaim에서는 예외가 발생하지 않을 것이기 때문이다.

즉 parseClaim에서 발생하는 ExpiredJwtException & MalformedJwtException은 Authentication 과정에서 발생하는 filter에서 걸러지는 부분이 아니라, reissue 요청으로 받은 accessToken을 검증하는 파트에서만 발생하는 예외가 된 것이다.

필터부분이 아니므로 ControllerAdvice로 편하게 처리할 수 있다

0개의 댓글