본문 바로가기
개발

JWT 심화

by hwan-da 2024. 8. 11.

보안을 위한 JWT 심화

토큰 사용 추적

  • 로그인 성공 JWT 발급 : 서버측 → 클라이언트로 JWT 발급
  • 권한이 필요한 모든 요청 : 클라이언트 → 서버측 JWT 전송
  • JWT는 매시간 수많은 요청을 위해 클라이언트의 JS 코드로 HTTP 통신을 통해 서버로 전달
  • 해커는 클라이언트 측에서 XSS를 이용하거나 HTTP 통신을 가로채서 토큰을 훔칠 수 있음
    • → 여러 기술을 도입하여 탈취를 방지하고 탈취당했을 시 대비 로직이 필요

다중 토큰 : RefreshToken과 생명 주기

여러 문제를 예방하기 위해 Access/Refresh 토큰 개념 등장

  • 자주 사용되는 토큰의 사용주기는 짧게(약 10분) 이 토큰이 만료되었을 때 함께 받은 Refresh 토큰(24시간 이상)으로 토큰을 재발급
  • 권한이 필요한 모든 요청 : Access 토큰으로 요청
    • Access 토큰만 요청하기 때문에 Refresh 토큰은 호출 및 전송율 빈도가 낮음
  • 권한이 알맞다는 가정하에 데이터 응답 / 토큰 만료 응답으로 나뉨
    • 토큰이 만료된 경우 Refresh 토큰으로 Access 토큰 발급
  • Access 토큰이 만료되었다는 요청이 돌아왔을 경우 프론트엔드 로직에 의해 발급 받은 Refresh 토큰을 가지고 서버의 특정 경로에 요청을 보내어 Access 토큰을 재발급 받음
  • 서버측에서는 Refresh 토큰을 검증 후 Access 토큰을 새로 발급

다중 토큰 구현 포인트

  • 로그인이 완료되면 successHandler에서 토큰 2개를 발급해 응답
    • 각 토큰은 각기 다른 생명주기, payload 정보를 가짐
  • Access 토큰 요청을 검증하는 JwtFilter에서 Access 토큰이 만료된 경우는 프론트 개발자와 협의된 상태 코드와 메세지를 응답
  • 프론트측 API 클라이언트(axios, fetch) 요청 시 Access 토큰 만료 요청이 오면 예외문을 통해 Refresh 토큰을 서버측으로 전송하고 Access 토큰을 발급 받는 로직 수행(기존 Access는 제거)
  • 서버측에서는 Refresh 토큰을 받을 엔드포인트(컨트롤러)를 구성하여 Refresh를 검증하고 Access를 응답

Refresh 토큰이 탈취되는 경우

Access 토큰이 탈취되더라도 생명주기가 짧아 피해 확률이 줄음

  • 하지만, Refresh 토큰도 탈취될 수 있음
  • Access / Refresh 토큰의 저장 위치 고려
    • 로컬 / 세션 스토리지 및 쿠키에 따라 XSS, CSRF 공격의 여부가 결정되기 때문에 각 토큰 사용처에 알맞은 저장소 설정
  • Refresh 토큰 Rotate
    • Access 토큰 갱신 위한 Refresh 토큰 요청 시 서버측에서 Refresh 토큰도 재발급을 진행하여 한 번 사용한 Refresh 토큰은 재사용하지 못하게 함

Access / Refresh 토큰 저장 위치

  • 로컬 스토리지 : XSS 공격에 취약 - Access 토큰 저장
  • httpOnly 쿠키 : CSRF 공격에 취약 - Refresh 토큰 저장

고려해야할 점

  • JWT의 탈취는 보통 XSS 공격으로 로컬 스토리지에 저장된 JWT를 탈취
  • 쿠키 방식은 CSRF 공격에 취약
  • Access 토큰은 로컬 스토리지에 저장
    • 짧은 생명 주기로 탈취에서 사용까지 기간이 매우 짧음
    • 에디터 및 업로더에서 XSS를 방어하는 로직을 작성하여 최대한 보호할 수 있지만 CSRF 공격의 경우 클릭 한 번으로 단시간에 요청이 진행되기 때문
    • 권한이 필요한 모든 경로에 사용되기 때문에 CSRF 공격의 위험보다는 XSS 공격을 받는 게 더 나을 수 있음
  • Refresh 토큰은 주로 쿠키에 저장
    • XSS 공격을 받을 수 있지만 httpOnly를 설정하면 방어 가능
    • CSRF 공격에 위험할 수 있음
    • 하지만, Refresh 토큰의 사용처는 토큰 재발급 경로 한 개임
    • Access 토큰이 접근하는 CRUD에는 취약하지만, 토큰 재발급 경로에서는 크게 피해를 입힐 만한 로직이 없음

Refresh 토큰 Rotate

Access 토큰이 만료되어 Refresh 토큰을 가지고 재발급을 진행할 때, Referesh 토큰 또한 재발급

로그아웃과 Refresh 토큰 주도권

  • 문제
    • 로그아웃을 구현하면 프론트 측에 존재하는 Access / Refresh 토큰을 제거
    • 프론트 측에서 요청을 보낼 JWT가 없기 때문에 로그아웃이 되었다고 생각하지만 이미 해커가 JWT를 복제했다면 요청이 수행
  • 이유
    • JWT를 발급해준 순간 서버측의 주도권은 없기 때문
      • 세션 방식은 STATE 하게 상태를 관리하여 주도권이 서버측에 있음
  • 방어 방법
    • Refresh 토큰은 발급과 함께 서버측에도 저장하여 요청이 올 때마다 저장소에 존재하는지 확인
    • 로그아웃을 진행하거나 탈취에 의해 피해가 진행되는 경우 서버측 저장소에서 해당 JWT를 삭제하여 피해 방어 가능
    • Refresh 토큰 블랙리스팅이라고 불림

로그인 시 메일 알림

  • 평소에 사용하지 않던 IP나 브라우저에서 접근할 경우 사용자의 계정으로 메일 알림
    • 이때, 내가 아닐 경우 “아니오”를 클릭하게되면 서버측 토큰 저장소에서 해당 유저에 대한 Refresh 토큰을 모두 제거하여 앞으로의 인증을 막을 수 있음

다중 토큰 발급

로그인 성공 시 다중 토큰 발급과 발급 위치

로그인 성공 이후 실행되는 successfulAuthentication() 또는 AuthentiacationSuccessHandler를 구현한 클래스에서 2개의 토큰 발급

AuthentiacationSuccessHandler

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        String username = authentication.getName();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        String access = jwtUtil.createJwt("access", username, role, 600000L);
        String refresh = jwtUtil.createJwt("referesh", username, role, 86400000L);

        response.addHeader("Authorization", "Bearer " + access);
        response.addCookie(createCookie("Authorization", refresh));
    }

JwtUtil 추가

    public String getCategory(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }

    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    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();
    }

Access 토큰 필터 : JwtFilter

  • Access 토큰이 만료된 경우 특정한 상태 코드 및 메세지를 응답해야 함

JwtFilter

package com.practice.oauthjwt.jwt;

import com.practice.oauthjwt.dto.CustomOAuth2User;
import com.practice.oauthjwt.dto.UserDTO;
import com.practice.oauthjwt.entity.UserEntity;
import io.jsonwebtoken.ExpiredJwtException;
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.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.io.PrintWriter;

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    // 쿠키에서 jwt를 꺼내서 jwt 검증을 해야하기 때문에 의존성 주입
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestUri = request.getRequestURI();
        if (requestUri.matches("^\\/login(?:\\/.*)?$") || requestUri.matches("^\\/oauth(?:\\/.*)?$")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("Authorization");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {

            filterChain.doFilter(request, response);

            return;
        }

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {
            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);

        // 엑세스 토큰이 아니면 다음 필터로 넘기지 않음
        if (!category.equals("access")) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // username, role 값을 획득
        String username = jwtUtil.getUsername(accessToken);
        String role = jwtUtil.getRole(accessToken);

        UserDTO userDTO = new UserDTO();
        userDTO.setUsername(username);
        userDTO.setRole(role);
        CustomOAuth2User customUserDetails = new CustomOAuth2User(userDTO);

        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

Refresh로 Access 토큰 재발급

  • 로그인 전송 → 다중 토큰 응답

    • API controller → AccessToken과 요청 전송 → Access 토큰 만료 경우 특정 응답(예외 던짐)
    • 예외 핸들링 → Refersh 토큰 전송 → 새 AccessToken 응답
  • reissue

    • 서비스 단으로 나눠서 구현하는 것을 권장

      package com.practice.oauthjwt.controller;
      
      import com.practice.oauthjwt.jwt.JwtUtil;
      import io.jsonwebtoken.ExpiredJwtException;
      import jakarta.servlet.http.Cookie;
      import jakarta.servlet.http.HttpServletRequest;
      import jakarta.servlet.http.HttpServletResponse;
      import lombok.RequiredArgsConstructor;
      import org.springframework.http.HttpStatus;
      import org.springframework.http.ResponseEntity;
      import org.springframework.stereotype.Controller;
      import org.springframework.web.bind.annotation.GetMapping;
      import org.springframework.web.bind.annotation.PostMapping;
      import org.springframework.web.bind.annotation.RequestMapping;
      import org.springframework.web.bind.annotation.RequestParam;
      
      @Controller
      @RequestMapping("/auth")
      @RequiredArgsConstructor
      public class AuthController {
      
        private final JwtUtil jwtUtil;
      
        @GetMapping("/reissue")
        public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
      
            // 쿠키에서 refresh 토큰 추출
            String refresh = null;
            Cookie[] cookies = request.getCookies();
            for (Cookie cookie : cookies) {
      
                if (cookie.getName().equals("refresh")) {
      
                    refresh = cookie.getValue();
                }
            }
      
            // refresh 유무 체크
            if (refresh == null) {
                return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
            }
      
            // refresh 만료 체크
            try {
                jwtUtil.isExpired(refresh);
            } catch (ExpiredJwtException e) {
                return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
            }
      
            // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
            String category = jwtUtil.getCategory(refresh);
      
            if (!category.equals("refresh")) {
                return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
            }
      
            String username = jwtUtil.getUsername(refresh);
            String role = jwtUtil.getRole(refresh);
      
            //make new JWT
            String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
      
            //response
            response.setHeader("Authrziation", "Bearer " + newAccess);
      
            return new ResponseEntity<>(HttpStatus.OK);
        }
      
      }
      

Refresh Rotate

Access 토큰 갱신 시 Refresh 토큰도 함께 갱신하는 방법

  • 장점
    • Refersh 토큰 교체로 보안성 강화
    • 로그인 지속시간 길어짐
  • 추가 구현 작업
    • 발급했던 Refresh 토큰을 모두 기억한 뒤, Rotate 이전의 Refresh 토큰은 사용하지 못하도록 해야 함

Reissue 코드 추가

        String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

        response.addCookie(createCookie("refresh", newRefresh));
  • 주의 사항
    • Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh들을 기억한 뒤 블랙리스트 처리를 진행하는 로직을 작성해야 함

Refresh 토큰 서버측 저장

구현 방법

  • 발급시
    • Refresh 토큰을 서버측 저장소에 저장
  • 갱신 시(Refresh rotate)
    • 기존 RefreshToken을 삭제하고 새로 발급한 RefreshToken을 저장

토큰 저장소 구현

RDB 또는 Redis와 같은 데이터베이스를 통해 Refresh 토큰을 저장

  • 이때 Redis의 경우 TTL 설정을 통해 생명주기가 끝이난 토큰은 자동으로 삭제할 수 있음

  • RefreshEntity

      package com.practice.oauthjwt.entity;
    
      import jakarta.persistence.Entity;
      import jakarta.persistence.GeneratedValue;
      import jakarta.persistence.GenerationType;
      import jakarta.persistence.Id;
      import lombok.Getter;
      import lombok.Setter;
    
      @Entity
      @Getter
      @Setter
      public class RefreshEntity {
    
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private long id;
    
          private String username;
    
          private String refresh;
    
          private String expiration;
    
      }
    
  • RefreshRepository

      package com.practice.oauthjwt.repository;
    
      import com.practice.oauthjwt.entity.RefreshEntity;
      import org.springframework.data.jpa.repository.JpaRepository;
      import org.springframework.transaction.annotation.Transactional;
    
      public interface AuthRepository extends JpaRepository<RefreshEntity, Long> {
    
          boolean existsByRefresh(String refresh);
    
          @Transactional
          void deleteByRefresh(String refresh);
      }
    

로그인 시 : LoginSuccessHandler

  • RefreshRepository 의존성 작성
  • successAuthentication() 일부 추가

Reissue 시 : ReissueController

  • RefreshRepository 의존성 작성
  • PutMapping(”/reissue”) 경로 메소드

Refresh 토큰 저장소에서 기한이 지난 토큰 삭제

TTL 설정을 통해 자동으로 Refersh 토큰을 삭제할 수 있음

  • 하지만, 토큰이 계속해서 쌓일 경우 용량 문제가 발생할 수 있음
  • 스케줄 작업을 통해 만료시간이 지난 토큰은 주기적으로 삭제하는 것이 좋음

로그아웃

  • 로그아웃 버튼 클릭시
    • 프론트엔드
      • 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰 전송
    • 백엔드
      • 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화
      • Refresh DB에서 해당 Refresh 토큰 삭제
        • 모든 계정에서 로그아웃 구현 시 username 기반으로 모든 Refresh 토큰 삭제
  • SpringSecurity에서의 로그아웃 구현 위치
    • 일반적으로 SpringSecuritydm의 기본 로그아웃 기능이 활성화 됨
    • 해당 로그아웃을 수행하는 클래스의 위치는 필터임
      • → 커스텀 필드도 Security 필터에 위치할 예정

로그아웃 필터 구현