ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] JWT - Refresh 토큰 발급
    Spring Framework 2024. 5. 21. 17:16

    참고 자료


    이전 실습과 이어지는 내용입니다.

    https://jngsngjn.tistory.com/17

     

    [Spring Security] JWT 간단히 실습해보기

    참고 자료더보기https://www.youtube.com/watch?v=NPRh2v7PTZg&list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ&index=1https://substantial-park-a17.notion.site/JWT-7a5cd1cf278a407fae9f35166da5ab03https://jwt.io목표스프링 시큐리티와 JWT를 사용하여

    jngsngjn.tistory.com


    목표

    • 로그인 성공 핸들러를 커스텀하여 사용자가 인증에 성공하면 Access 토큰과 Refresh 토큰을 발급하는 다중 토큰 발급 방식을 사용해 본다.
    • Access 토큰을 재발급 받기 위해 요청이 들어올 때 그에 맞는 컨트롤러 클래스에서 Refresh 토큰을 검증하도록 구현한다.

     

    Refresh 토큰의 필요성

    • 단일 토큰 사용 시 문제점
      • Access 토큰만을 발급하는 단일 토큰 방식을 사용하면 보안과 사용자 경험 측면에서 문제가 발생한다. Access 토큰의 만료 기간을 짧게 하는 경우, 사용자가 매번 로그인을 해야 하는 문제가 발생하고 만료 기간을 길게 하는 경우 해커에 의해 토큰이 탈취되었을 경우 보안에 문제가 생길 수 있다.
    • 다중 토큰 사용
      • 다중 토큰 방식은 사용자가 인증에 성공한 경우 Access 토큰과 Refresh 토큰을 발급하는 방식이다. Access 토큰은 짧은 만료 시간을 가지고 있으며, Refresh 토큰은 상대적으로 긴 만료 시간을 가지고 있다. 사용자의 Access 토큰이 만료되었을 경우, Refresh 토큰을 통해 Access 토큰을 재발급 받는다.

     

    Refresh 토큰 적용 시 문제 해결

    Refresh 토큰을 적용해도 보안 문제는 사라지지 않는다. Refresh 토큰은 Access 토큰과 달리 자주 사용되지 않아 탈취당할 위험이 상대적으로 적긴 하지만, 만료 시간이 길기 때문에 탈취 당했을 경우, 매우 심각한 보안 문제가 될 수 있다. 이 문제를 최대한 방지하기 위해 다음과 같은 해결책을 적용할 수 있다.

    1. Access 토큰은 로컬 스토리지에, Refresh 토큰은 쿠키에 저장한다.
      • 로컬 스토리지에 저장된 데이터는 XSS 공격에 취약하지만, 짧은 만료 시간을 가지고 있기 때문에 XSS를 방어하는 로직을 작성하여 최대한 보호할 수 있다.
      • 쿠키에 저장된 데이터는 httpOnly 설정을 통해 XSS 공격으로부터 방어가 가능하며, CSRF 공격에 노출되어도 Refresh 토큰은 주로 Access 토큰 재발급을 위해서만 사용되기 때문에 Access 토큰에 비해 상대적으로 낮은 위험성을 가진다.
    2. Refresh 토큰 Rotate 전략을 사용한다.
      • Access 토큰이 만료되어 Refresh 토큰을 가지고 새로운 Access 토큰을 발급할 때, Refresh 토큰도 새로 발급하여 제공한다. 이와 동시에 기존 Refresh 토큰은 무효화해야 한다.
    3. 로그아웃 기능을 통해 JWT 탈취를 방어할 수 있다.
      • 프론트엔드 : 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰 전송
      • 백엔드 : 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 및 데이터베이스에서 해당 Refresh 토큰 삭제 (모든 계정에서 로그아웃 구현 시 username 기반으로 모든 Refresh 토큰 삭제)
    4. 데이터베이스에 Refresh 토큰 저장하고 관리한다.
      • Rotate 전략 사용 시 새로운 Refresh 토큰을 발급하고 기존 토큰은 삭제해야 한다. 이를 위해 Refresh 토큰을 발급할 때 데이터베이스 저장해야 한다.
      • 데이터베이스에 저장하지 않으면 Refresh 토큰이 탈취당했을 때 서버는 만료 시간이 다 되길 기다리는 수 밖에 없다. Refresh 토큰을 데이터베이스에 관리함으로써 탈취당한 Refresh 토큰을 삭제하여 방어할 수 있다.

    Refresh 토큰 엔티티

    RefreshEntity

    @Data
    @Entity
    public class RefreshEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String username;
        private String refresh;
        private LocalDateTime expiration;
    }

     

     

    RefreshRepository

    • 기한이 지난 토큰을 주기적으로 삭제하기 위해 deleteExpiredRefreshTokens() 메서드를 정의했다.
    public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {
        
        Boolean existsByRefresh(String refresh);
    
        @Transactional
        void deleteByRefresh(String refresh);
        
        // 하루에 한 번씩 만료된 토큰 삭제
        @Transactional
        @Modifying
        @Query("DELETE FROM RefreshEntity r WHERE r.expiration < CURRENT_TIMESTAMP")
        void deleteExpiredRefreshTokens();
    }
    

     

    다중 토큰 발급

     

    JWTUtil

    • Access 토큰과 Refresh 토큰을 구분하기 위해 payload에 category 항목을 추가하고, 토큰을 판단하는 메서드인 getCategory() 메서드를 추가했다.
    @Component
    public class JWTUtil {
    
        private final SecretKey secretKey;
    
        public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
            this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
        }
    
        public String getUsername(String token) {
            return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
        }
    
        public String getRole(String token) {
            return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
        }
    
        public void isExpired(String token) {
            Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration();
        }
    		
        public String getCategory(String token) {
            return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
        }
    
        public String createJwt(String category, String username, String role, Long expiredMs) {
            return Jwts.builder()
                    .claim("category", category)
                    .claim("username", username)
                    .claim("role", role)
                    .issuedAt(new Date(System.currentTimeMillis()))
                    .expiration(new Date(System.currentTimeMillis() + expiredMs))
                    .signWith(secretKey)
                    .compact();
        }
    }

     

     

    LoginFilter

    • 인증 성공 시 Access 토큰은 헤더에, Refresh 토큰은 쿠키에 담아서 전송한다. 클라이언트측에서 헤더에 Access 토큰을 로컬 스토리지에 저장하면 된다.
    • 발급한 Refresh 토큰을 기억하기 위해 데이터베이스에 저장한다.
    @RequiredArgsConstructor
    public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    
        private final JWTUtil jwtUtil;
        private final RefreshRepository refreshRepository;
        private final AuthenticationManager authenticationManager;
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
            String username = obtainUsername(request);
            String password = obtainPassword(request);
    
            System.out.println(username);
    
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
            return authenticationManager.authenticate(authToken);
        }
    
        // 로그인 성공 시
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
    
            String username = authentication.getName();
            String role = authentication.getAuthorities().iterator().next().getAuthority();
    
            // 토큰 생성
            String access = jwtUtil.createJwt("access", username, role, 600000L);
            String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
    
            // DB에 refresh 토큰 저장
            addRefreshEntity(username, refresh, 86400000L);
    
            // 응답 설정
            response.setHeader("access", access);
            response.addCookie(createCookie("refresh", refresh));
            response.setStatus(HttpStatus.OK.value());
        }
    
        private void addRefreshEntity(String username, String refresh, Long expiredMs) {
            LocalDateTime expiration = LocalDateTime.now().plusSeconds(expiredMs / 1000);
    
            RefreshEntity refreshEntity = new RefreshEntity();
            refreshEntity.setUsername(username);
            refreshEntity.setRefresh(refresh);
            refreshEntity.setExpiration(expiration);
    
            refreshRepository.save(refreshEntity);
        }
    
        private Cookie createCookie(String key, String value) {
    
            Cookie cookie = new Cookie(key, value);
            cookie.setMaxAge(24 * 60 * 60);
            // cookie.setSecure(true);
            // cookie.setPath("/");
            cookie.setHttpOnly(true);
    
            return cookie;
        }
    
        @Override
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
            response.setStatus(401);
        }
    }

     

    JWT 필터 수정

     

    JWTFilter

    • JWT에 카테고리를 추가했기 때문에 Access 토큰만을 검증하는 JWTFilter를 수정해야 한다.
    • 만료 기간이 지났을 경우 ExpiredJwtException 예외가 발생한다.
    @RequiredArgsConstructor
    public class JWTFilter extends OncePerRequestFilter {
    
        private final JWTUtil jwtUtil;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
            String accessToken = request.getHeader("access");
    
            if (accessToken == null) {
                filterChain.doFilter(request, response);
                return;
            }
    
            // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음
            try {
                jwtUtil.isExpired(accessToken);
            } catch (ExpiredJwtException e) {
    
                PrintWriter writer = response.getWriter();
                writer.print("access token expired");
    
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
                return;
            }
    
            // 토큰이 access인지 확인, 아니면 다음 필터로 넘기지 않음
            String category = jwtUtil.getCategory(accessToken);
    
            if (!category.equals("access")) {
    
                PrintWriter writer = response.getWriter();
                writer.print("invalid access token");
    
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
    
            // username, role 값을 획득
            UserEntity userEntity = new UserEntity();
            userEntity.setUsername(jwtUtil.getUsername(accessToken));
            userEntity.setRole(jwtUtil.getRole(accessToken));
            
            // 임시 세션에 저장
            CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
            Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authToken);
    
            filterChain.doFilter(request, response);
        }
    }

     

    Access 토큰 재발급

    • Access 토큰이 만료되었을 때 서버에서 특정 상태 코드가 반환되면 프론트 측 예외 핸들러에서 /reissue 경로로 요청을 하는 것으로 가정한다.
    • /reissue 경로는 인증되지 않은 사용자도 접근해야 하기 때문에 permitAll() 설정을 해주어야 한다.
    • Access 토큰 재발급에 사용된 Refresh 토큰을 제거하고 새로운 Refresh 토큰을 만들어 응답하는 Rotate 전략을 사용한다. 이때 기존 토큰을 삭제하지 않으면 기존 토큰이 유효한 경우 Access 토큰을 발급 받을 수 있기 때문에 위험하다. 따라서 기존 토큰을 꼭 삭제해 주어야 한다.
    • ReissueService에서 @Scheduled 어노테이션을 사용하여 매일 자정마다 만료 기간이 지난 토큰을 삭제하도록 한다.

     

    ReissueController

    @RestController
    @RequiredArgsConstructor
    public class ReissueController {
    
        private final JWTUtil jwtUtil;
        private final ReissueService reissueService;
    
        @PostMapping("/reissue")
        public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
    
            String result = reissueService.checkRefreshToken(request);
    
            if (result == null) {
                return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
            }
    
            if (result.equals("expired")) {
                new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
            }
    
            if (result.equals("invalid")) {
                return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
            }
            
            String refreshToken = result.split(" ")[1];
    
            String username = jwtUtil.getUsername(refreshToken);
            String role = jwtUtil.getRole(refreshToken);
    
            // 새로운 토큰 발급 (rotate)
            String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
            String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
    
            // DB에 기존 refresh 토큰을 삭제하고 새로운 refresh 토큰 저장
            reissueService.deleteByRefresh(refreshToken);
            reissueService.saveNewRefreshToken(username, newRefresh, 86400000L);
            
            response.setHeader("access", newAccess);
            response.addCookie(reissueService.createCookie("refresh", newRefresh));
    
            return new ResponseEntity<>(HttpStatus.OK);
        }
    }

     

     

    ReissueService

    @Service
    @RequiredArgsConstructor
    public class ReissueService {
    
        private final JWTUtil jwtUtil;
        private final RefreshRepository refreshRepository;
    
        public String checkRefreshToken(HttpServletRequest request) {
            String refreshToken = null;
    
            Cookie[] cookies = request.getCookies();
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("refresh")) {
                    refreshToken = cookie.getValue();
                }
            }
    
            if (refreshToken == null) {
                return null;
            }
    
            try {
                jwtUtil.isExpired(refreshToken);
            } catch (ExpiredJwtException e) {
                return "expired";
            }
    
            String category = jwtUtil.getCategory(refreshToken);
    
            if (!category.equals("refresh")) {
                return "invalid";
            }
    
            Boolean isExist = refreshRepository.existsByRefresh(refreshToken);
            if (!isExist) {
                return "invalid";
            }
    
            return "ok " + refreshToken;
        }
    
        public void deleteByRefresh(String refreshToken) {
            refreshRepository.deleteByRefresh(refreshToken);
        }
    
        public void saveNewRefreshToken(String username, String token, Long expiredMs) {
            LocalDateTime expiration = LocalDateTime.now().plusSeconds(expiredMs / 1000);
    
            RefreshEntity refreshEntity = new RefreshEntity();
            refreshEntity.setUsername(username);
            refreshEntity.setRefresh(token);
            refreshEntity.setExpiration(expiration);
    
            refreshRepository.save(refreshEntity);
        }
    
        public Cookie createCookie(String key, String value) {
            Cookie cookie = new Cookie(key, value);
            cookie.setMaxAge(24 * 60 * 60);
            // cookie.setSecure(true);
            // cookie.setPath("/");
            cookie.setHttpOnly(true);
    
            return cookie;
        }
    
        // 매일 자정에 실행
        @Scheduled(cron = "0 0 0 * * *")
        public void cleanExpiredRefreshTokens() {
            refreshRepository.deleteExpiredRefreshTokens();
        }
    }

     

    로그아웃 필터 구현 및 등록

     

    CustomLogoutFilter

    • 데이터베이스에 저장 중인 Refresh 토큰을 삭제하고 Refresh 토큰 쿠키를 null로 변경한다.
    @RequiredArgsConstructor
    public class CustomLogoutFilter extends GenericFilterBean {
    
        private final JWTUtil jwtUtil;
        private final RefreshRepository refreshRepository;
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
            doFilter((HttpServletRequest) request, (HttpServletResponse) response, filterChain);
        }
    
        private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
            // 로그아웃 경로와 HTTP 메서드 검증
            String requestUri = request.getRequestURI();
            if (!requestUri.matches("^\\\\/logout$")) {
                filterChain.doFilter(request, response);
                return;
            }
    
            String requestMethod = request.getMethod();
            if (!requestMethod.equals("POST")) {
                filterChain.doFilter(request, response);
                return;
            }
    
            String refresh = null;
            Cookie[] cookies = request.getCookies();
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("refresh")) {
                    refresh = cookie.getValue();
                }
            }
    
            if (refresh == null) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
    
            try {
                jwtUtil.isExpired(refresh);
            } catch (ExpiredJwtException e) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
    
            String category = jwtUtil.getCategory(refresh);
            if (!category.equals("refresh")) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
    
            // DB에 저장되어 있는지 확인
            Boolean isExist = refreshRepository.existsByRefresh(refresh);
            if (!isExist) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }
    
            // 로그아웃 진행
            // DB에서 제거
            refreshRepository.deleteByRefresh(refresh);
    
            // refresh 토큰 Cookie 값 0
            Cookie cookie = new Cookie("refresh", null);
            cookie.setMaxAge(0);
            cookie.setPath("/");
    
            response.addCookie(cookie);
            response.setStatus(HttpServletResponse.SC_OK);
        }
    }

     

    로그아웃 필터 등록

    http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class);

     


     

    테스트

    로그인 시 다중 토큰 발급 확인과 Refresh 토큰 DB 저장 확인


    Refresh 토큰으로 Access 토큰 재발급 확인과 Rotate 확인

    • 토큰 뒷 부분을 보면 처음 발급 받았던 Refresh 토큰과 다른 것을 확인할 수 있었다.


    로그아웃 시 데이터베이스에 Refresh 토큰 삭제 확인


     

    이번 실습으로 JWT를 보안 측면에서 어떻게 활용하는지 자세히 알 수 있었다. JWT를 사용하면 서버 측에서는 JWT의 만료 기간만 확인하면 되어 편리하다는 장점이 있었다. 그 장점만을 누리려고 하면 여러가지 보안 문제가 발생했다. 기능을 쉽게 사용하려고 하면 보안 문제가, 보안을 유지하려고 하면 구현이 다소 어려워지는 trade-off 관계를 확인했다. 조만간 시작하는 웹 프로젝트에서 JWT를 사용해볼 계획인데, 이번 실습에서 배운 Refresh 토큰과 rotate 등을 도입하여 클라이언트 측과 직접 상호작용하며 구현해보고 싶은 마음이 들었다.