Spring Boot 3.1 & Spring Security 6: JWT 검증 리팩토링 (11편)

2023. 9. 4. 19:42·Spring/Spring Security
반응형

JWT 필터 스프링 시큐리티 코드 리팩토링을 진행했다.

 


 

1.  JwtAuthorizationFilter 클래스를 리팩토링 했다.


  • 기존에는 한줄로 이루어져있던 코드를 extract method로 각각 역할별로 추출했다.

 

import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipia.web.config.security.jwt.TokenUtils;
import com.recipia.web.domain.user.constant.RoleType;
import com.recipia.web.exception.ErrorCode;
import com.recipia.web.exception.RecipiaApplicationException;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
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.UserDetailsService;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;

/**
 * 지정한 URL별 JWT의 유효성 검증을 수행하며 직접적인 사용자 인증을 확인합니다.
 */
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private static final AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 문자열 리터럴은 상수로 선언하여 사용하는 것이 좋다. 이렇게 하면 코드의 가독성이 향상되며, 나중에 변경이 필요할 때 한 곳에서만 수정하면 된다.
     */
    private static final String LOGIN_PATH = "/login";
    private static final String ROOT_PAGE_PATH = "/main/rootPage";
    private static final String JWT_COOKIE_NAME = "jwt";

    // 예외 처리할 경로 리스트 선언
    private static final List<String> EXCLUDE_PATHS = Arrays.asList(
            "/user/login",
            "/login",
            "/css/**",
            "/js/**",
            "/images/**",
            "/signUp/form",
            "/favicon.ico",
            "/signUp/action/createUser",
            "/resources/**"  // 'src/main/resources'에 있는 모든 리소스에 대한 경로를 여기에 추가
    );


    /**
     * 요청 경로와 HTTP 메서드를 확인하여 인증 및 인가 필터링을 건너뛸지 결정한다.
     * 예외 처리 경로 목록(EXCLUDE_PATHS) 또는 HTTP OPTIONS 메서드의 경우 필터링을 건너뛴다.
     *
     * @param request 클라이언트로부터의 요청 객체
     * @param response 서버의 응답 객체
     * @param filterChain 필터 체인
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
//        log.info("JwtAuthorizationFilter 시작: " + request.getRequestURI()); // 로그 추가

        String token = getTokenFromCookiesWithoutException(request.getCookies());

        if (!StringUtils.hasText(token)) {
            if (!request.getRequestURI().equals(LOGIN_PATH)) {
                response.sendRedirect(LOGIN_PATH);
                return;
            }
        } else if (request.getRequestURI().equals(LOGIN_PATH)) {
            response.sendRedirect(ROOT_PAGE_PATH);
            return;
        }

        if (shouldExclude(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            validateAndSetAuthentication(token);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            handleException(response, e);
        }

//        log.info("JwtAuthorizationFilter 종료: " + request.getRequestURI()); // 로그 추가
    }

    // 예외를 발생시키지 않는 버전의 getTokenFromCookies 메서드
    private String getTokenFromCookiesWithoutException(Cookie[] cookies) {
        return Optional.ofNullable(cookies)
                .flatMap(cookieList -> Arrays.stream(cookieList).filter(cookie -> JWT_COOKIE_NAME.equals(cookie.getName())).findFirst())
                .map(Cookie::getValue)
                .orElse(null);
    }

    /**
     * 주어진 요청의 URI가 예외 처리 대상 경로에 포함되어 있는지, 또는 HTTP OPTIONS 메서드인지 확인한다.
     * CORS preflight 요청 때문에 이런 처리가 필요하다.
     *
     * @param request 클라이언트로부터의 요청 객체
     * @return 예외 처리 대상이면 true, 아니면 false
     */
    private boolean shouldExclude(HttpServletRequest request) {
        return EXCLUDE_PATHS.stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())) || "OPTIONS".equalsIgnoreCase(request.getMethod());
    }

    /**
     * 주어진 JWT 토큰의 유효성을 검증하고, 유효하다면 해당 사용자의 인증 정보를 SecurityContextHolder에 설정한다.
     * 이 메서드는 JWT 토큰에서 로그인 ID와 RoleType(사용자 권한)을 추출한다. 이후, RoleType을 바탕으로 권한 목록을 생성하고,
     * 이를 포함하는 UsernamePasswordAuthenticationToken 인증 객체를 생성한다. 생성된 인증 객체는 SecurityContextHolder의 Context에 설정되며,
     * 이로써 이후의 요청에서 해당 사용자가 인증된 것으로 처리된다.
     *
     * @param token 검증할 JWT 토큰
     * @throws RecipiaApplicationException JWT 토큰이 유효하지 않을 경우 발생
     */
    private void validateAndSetAuthentication(String token) {
        log.info("validateAndSetAuthentication 시작: " + token); // 로그 추가

        if (TokenUtils.isValidToken(token)) {
            String loginId = TokenUtils.getUserIdFromToken(token);
            RoleType roleType = TokenUtils.getRoleFromToken(token); // JWT 토큰에서 RoleType 정보를 추출합니다.
            List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(roleType.name())); // RoleType을 바탕으로 권한 목록을 생성합니다.

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(loginId, null, authorities);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else {
            throw new RecipiaApplicationException(ErrorCode.TOKEN_NOT_VALID);
        }
    }


    /**
     * 예외 발생 시, 해당 예외를 로그에 기록하고 클라이언트에게 오류 메시지를 JSON 형태로 전송한다.
     *
     * @param response 서버의 응답 객체
     * @param e 발생한 예외
     */
    private void handleException(HttpServletResponse response, Exception e) throws IOException {
        Map<String, Object> errorResponse = jsonResponseWrapper(e);
        log.error((String) errorResponse.get("message"), e);

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json");

        try (PrintWriter printWriter = response.getWriter()) {
            Map<String, Object> jsonObject = new HashMap<>();
            jsonObject.put("error", true);
            jsonObject.put("message", "로그인 에러");
            printWriter.print(new ObjectMapper().writeValueAsString(jsonObject));
        }
    }

    /**
     * 주어진 예외에 따라 적절한 에러 메시지를 생성하고, 이를 JSON 객체로 변환하여 반환한다.
     *
     * @param e 발생한 예외
     * @return 에러 메시지를 포함한 JSON 객체
     */
    private Map<String, Object> jsonResponseWrapper(Exception e) {
        String resultMessage;

        if (e instanceof ExpiredJwtException) {
            resultMessage = "JWT 토큰기간 만료";
        } else if (e instanceof SecurityException) {
            resultMessage = "허용된 JWT 토큰이 아닙니다.";
        } else if (e instanceof JwtException) {
            resultMessage = "토큰을 parsing하던중 예외가 발생했습니다.";
        } else {
            resultMessage = "JWT 토큰 에러입니다.";
        }

        Map<String, Object> jsonMap = new HashMap<>();
        jsonMap.put("status", 401);
        jsonMap.put("code", "9999");
        jsonMap.put("message", resultMessage);
        jsonMap.put("reason", e.getMessage());

        return jsonMap;
    }

}

 


 

2. 코드 설명


  • 먼저 문자열들은 따로 상수로 선언했다. 이러면 코드의 가독성이 향상되고 나중에 변경하기도 쉽다.
/**
 * 문자열 리터럴은 상수로 선언하여 사용하는 것이 좋다. 이렇게 하면 코드의 가독성이 향상되며, 나중에 변경이 필요할 때 한 곳에서만 수정하면 된다.
 */
private static final String LOGIN_PATH = "/login";
private static final String ROOT_PAGE_PATH = "/main/rootPage";
private static final String JWT_COOKIE_NAME = "jwt";
    • 예외 처리할 경로 또한 리스트로 선언해 두었다.
// 예외 처리할 경로 리스트 선언
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
        "/user/login",
        "/login",
        "/css/**",
        "/js/**",
        "/images/**",
        "/signUp/form",
        "/favicon.ico",
        "/signUp/action/createUser",
        "/resources/**"  // 'src/main/resources'에 있는 모든 리소스에 대한 경로를 여기에 추가
);
  • 그리고 본 메서드인 doFilterInternal()를 선언했다.
/**
 * 요청 경로와 HTTP 메서드를 확인하여 인증 및 인가 필터링을 건너뛸지 결정한다.
 * 예외 처리 경로 목록(EXCLUDE_PATHS) 또는 HTTP OPTIONS 메서드의 경우 필터링을 건너뛴다.
 *
 * @param request 클라이언트로부터의 요청 객체
 * @param response 서버의 응답 객체
 * @param filterChain 필터 체인
 */
@Override
protected void doFilterInternal(
        HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain filterChain
) throws ServletException, IOException {

    String token = getTokenFromCookiesWithoutException(request.getCookies());

    // 토큰이 없으면 로그인 페이지로 redirect시킨다.
    if (!StringUtils.hasText(token)) {
        if (!request.getRequestURI().equals(LOGIN_PATH)) {
            response.sendRedirect(LOGIN_PATH);
            return;
        }
    } else if (request.getRequestURI().equals(LOGIN_PATH)) {
        response.sendRedirect(ROOT_PAGE_PATH);
        return;
    }

    // 제외 경로에 존재하는 url이면 다음 filterChain으로 이동시킨다.
    if (shouldExclude(request)) {
        filterChain.doFilter(request, response);
        return;
    }

    // 검증작업을 하고 다음 필터로 넘긴다.
    try {
        validateAndSetAuthentication(token);
        filterChain.doFilter(request, response);
    } catch (Exception e) {
        handleException(response, e);
    }

}
  • 예외를 발생시키지 않고 토큰을 가져오는 메서드 (예외처리를 하면 필터를 탈때마다 예외로그가 너무 많이 남아서 알아볼수가 없었다.)
// 예외를 발생시키지 않는 버전의 getTokenFromCookies 메서드
private String getTokenFromCookiesWithoutException(Cookie[] cookies) {
    return Optional.ofNullable(cookies)
            .flatMap(cookieList -> Arrays.stream(cookieList).filter(cookie -> JWT_COOKIE_NAME.equals(cookie.getName())).findFirst())
            .map(Cookie::getValue)
            .orElse(null);
}
  • 이제 주어진 요청의 URI로 예외 처리 대상 경로인지, HTTP OPTIONS 메서드인지 확인한다. 이건 CORS preflight 요청 때문에 이런 처리가 필요하다.
  • EXCLUDE_PATHS는 상단에 선언한 예외 처리 대상 리스트이다.
/**
 * 주어진 요청의 URI가 예외 처리 대상 경로에 포함되어 있는지, 또는 HTTP OPTIONS 메서드인지 확인한다.
 * CORS preflight 요청 때문에 이런 처리가 필요하다.
 *
 * @param request 클라이언트로부터의 요청 객체
 * @return 예외 처리 대상이면 true, 아니면 false
 */
private boolean shouldExclude(HttpServletRequest request) {
    return EXCLUDE_PATHS.stream()
            .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())) || "OPTIONS".equalsIgnoreCase(request.getMethod());
}
  • 주어진 JWT 토큰의 유효성을 검증한다.
/**
 * 주어진 JWT 토큰의 유효성을 검증하고, 유효하다면 해당 사용자의 인증 정보를 SecurityContextHolder에 설정한다.
 * 이 메서드는 JWT 토큰에서 로그인 ID와 RoleType(사용자 권한)을 추출한다. 이후, RoleType을 바탕으로 권한 목록을 생성하고,
 * 이를 포함하는 UsernamePasswordAuthenticationToken 인증 객체를 생성한다. 생성된 인증 객체는 SecurityContextHolder의 Context에 설정되며,
 * 이로써 이후의 요청에서 해당 사용자가 인증된 것으로 처리된다.
 *
 * @param token 검증할 JWT 토큰
 * @throws RecipiaApplicationException JWT 토큰이 유효하지 않을 경우 발생
 */
private void validateAndSetAuthentication(String token) {
    log.info("validateAndSetAuthentication 시작: " + token); // 로그 추가

    if (TokenUtils.isValidToken(token)) {
        String loginId = TokenUtils.getUserIdFromToken(token);
        RoleType roleType = TokenUtils.getRoleFromToken(token); // JWT 토큰에서 RoleType 정보를 추출합니다.
        List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(roleType.name())); // RoleType을 바탕으로 권한 목록을 생성합니다.

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(loginId, null, authorities);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    } else {
        throw new RecipiaApplicationException(ErrorCode.TOKEN_NOT_VALID);
    }
}
  • 마지막으로 예외 처리 로직을 작성한다.
/**
 * 예외 발생 시, 해당 예외를 로그에 기록하고 클라이언트에게 오류 메시지를 JSON 형태로 전송한다.
 *
 * @param response 서버의 응답 객체
 * @param e 발생한 예외
 */
private void handleException(HttpServletResponse response, Exception e) throws IOException {
    Map<String, Object> errorResponse = jsonResponseWrapper(e);
    log.error((String) errorResponse.get("message"), e);

    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json");

    try (PrintWriter printWriter = response.getWriter()) {
        Map<String, Object> jsonObject = new HashMap<>();
        jsonObject.put("error", true);
        jsonObject.put("message", "로그인 에러");
        printWriter.print(new ObjectMapper().writeValueAsString(jsonObject));
    }
}

/**
 * 주어진 예외에 따라 적절한 에러 메시지를 생성하고, 이를 JSON 객체로 변환하여 반환한다.
 *
 * @param e 발생한 예외
 * @return 에러 메시지를 포함한 JSON 객체
 */
private Map<String, Object> jsonResponseWrapper(Exception e) {
    String resultMessage;

    if (e instanceof ExpiredJwtException) {
        resultMessage = "JWT 토큰기간 만료";
    } else if (e instanceof SecurityException) {
        resultMessage = "허용된 JWT 토큰이 아닙니다.";
    } else if (e instanceof JwtException) {
        resultMessage = "토큰을 parsing하던중 예외가 발생했습니다.";
    } else {
        resultMessage = "JWT 토큰 에러입니다.";
    }

    Map<String, Object> jsonMap = new HashMap<>();
    jsonMap.put("status", 401);
    jsonMap.put("code", "9999");
    jsonMap.put("message", resultMessage);
    jsonMap.put("reason", e.getMessage());

    return jsonMap;
}

 


 

3. JwtTokenInterceptor 리팩토링


  • 기존 클래스의 로직에서 필요없는 부분을 제거했다.
import com.recipia.web.config.security.jwt.TokenUtils;
import com.recipia.web.exception.ErrorCode;
import com.recipia.web.exception.RecipiaApplicationException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * JwtTokenInterceptor는 JWT 토큰을 검증하는 역할을 수행한다.
 * <p>
 * HTTP 요청이 들어올 때, 쿠키에서 "jwt"라는 이름을 가진 토큰을 추출한다.
 * 추출한 토큰이 유효한 경우, 요청을 그대로 진행한다.
 * 토큰이 유효하지 않거나 존재하지 않는 경우, 예외를 발생시킨다.
 * </p>
 * 특이사항으로는 /favicon.ico 요청에 대해서는 토큰 검증을 수행하지 않는다.
 */

@Slf4j
@Component
public class JwtTokenInterceptor implements HandlerInterceptor {

    /**
     * preHandle 메서드는 HTTP 요청이 Controller로 들어가기 전에 수행된다.
     *
     * @return 토큰이 유효한 경우 true, 그렇지 않으면 예외를 발생시킨다.
     */

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull Object handler
    ) {

        // favicon.ico 요청에 대한 JWT 토큰 검증을 건너뛰기
        if (request.getRequestURI().equals("/favicon.ico")) {
            return true;
        }

        String token = null;

        // 쿠키에서 JWT 토큰 가져오기
        jakarta.servlet.http.Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (jakarta.servlet.http.Cookie cookie : cookies) {
                if (cookie.getName().equals("jwt")) {
                    token = cookie.getValue();
                    break;
                }
            }
        }

        if (token != null) {
            if (TokenUtils.isValidToken(token)) {
                String userId = TokenUtils.getUserIdFromToken(token);
                if (userId == null) {
                    log.debug("token isn't userId");
                    throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_NOT_MATCH);
                }
                return true;
            } else {
                throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_INVALID);
            }
        } else {
            throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_IS_NULL);
        }
    }


}

 

 


 

4. 변경사항 정리


1. JwtFilter 관련해서 필요없어진 코드는 제거했다. (삭제처리함)

2. JwtTokenInterceptor 코드도 검증 로직을 간략화 했다.

3. 전체적인 코드를 extract method로 보기쉽게 분리했다. (필요없는 로직은 제거 및 리팩토링으로 고도화)

 

  • 지금까지 만든 패키지 구성 이미지

패키지 경로
패키지 경로

 


 

 

 

2023.09.04 - [SpringBoot 개발/Spring Security] - Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편)

 

Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편)

이번에는 필요없는 필터는 없애고 최적화를 진행하기 위해 Security Config를 리팩토링 했다. 1. SecurityConfig 리팩토링 앞의 게시글을 확인해보면 작성한 코드가 있기에 변경된 메서드만 설명하겠다.

curiousjinan.tistory.com

 

2023.08.08 - [SpringBoot 개발/Spring Security] - Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편)

 

Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편)

지금까지 만든 시큐리티의 동작을 설명하겠다. 1. 로그인 과정 로그인 페이지 접근: 사용자는 웹 브라우저에서 로그인 페이지(/login)에 접근한다. 로그인 페이지에는 사용자 이름과 비밀번호를

curiousjinan.tistory.com

 

반응형

'Spring > Spring Security' 카테고리의 다른 글

Spring Security와 CORS: 크로스 도메인 요청 처리 기법 알아보기  (0) 2023.10.21
Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편)  (0) 2023.09.04
Spring Security 6 이해하기: 동작 원리와 보안 기능 탐구  (0) 2023.09.04
Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편)  (0) 2023.08.08
Spring Boot 3.1 & Spring Security 6: 로그인 & 메인 페이지 컨트롤러 (9편)  (0) 2023.08.08
'Spring/Spring Security' 카테고리의 다른 글
  • Spring Security와 CORS: 크로스 도메인 요청 처리 기법 알아보기
  • Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편)
  • Spring Security 6 이해하기: 동작 원리와 보안 기능 탐구
  • Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편)
Stark97
Stark97
문의사항 또는 커피챗 요청은 링크드인 메신저를 보내주세요! : https://www.linkedin.com/in/writedev/
  • Stark97
    오늘도 개발중입니다
    Stark97
  • 전체
    오늘
    어제
    • 분류 전체보기 (246) N
      • 개발지식 (20)
        • 스레드(Thread) (8)
        • WEB, DB, GIT (3)
        • 디자인패턴 (8)
      • JAVA (21)
      • Spring (88)
        • Spring 기초 지식 (35)
        • Spring 설정 (6)
        • JPA (7)
        • Spring Security (17)
        • Spring에서 Java 활용하기 (8)
        • 테스트 코드 (15)
      • 아키텍처 (6)
      • MSA (15) N
      • DDD (11) N
      • gRPC (9)
      • Apache Kafka (18)
      • DevOps (23)
        • nGrinder (4)
        • Docker (1)
        • k8s (1)
        • 테라폼(Terraform) (12)
      • AWS (32)
        • ECS, ECR (14)
        • EC2 (2)
        • CodePipeline, CICD (8)
        • SNS, SQS (5)
        • RDS (2)
      • notion&obsidian (3)
      • 동아리 (0)
  • 링크

    • notion기록
    • 깃허브
    • 링크드인
  • hELLO· Designed By정상우.v4.10.0
Stark97
Spring Boot 3.1 & Spring Security 6: JWT 검증 리팩토링 (11편)
상단으로

티스토리툴바