Notice
Recent Posts
Recent Comments
Link
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

임도현의 성장

Spring-Boot JWT Access Token Refresh Token 본문

Spring Boot

Spring-Boot JWT Access Token Refresh Token

림도현 2024. 12. 7. 22:54
JWT (Json Web Token) 란?

JWT는 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용하느 토큰이다. 웹 상에서 정보를 Json형태로 주고 받기 위해 표준규약에 따라 생성한 암호화된 토큰으로 복잡하고 읽을 수 없는 String 형태로 저장되어있다. 

 

😎 JWT 구성요소

 

헤더(Header), 페이로드(Payload), 서명(Signature)세 파트로 나눠져 있다.

  • 헤더(Header)
    • 어떠한 알고리즘으로 암호화 할 것인지 어떠한 토큰을 사용할 것 인지에 대한 정보가 들어있다.
  • 페이로드(Payload)
    • 전달하려는 정보(사용자의 ID나 데이터들, 이것들을 클레임이라고 부름)가 들어있다. Payload에 있는 내용은 수정이 가능하며 더 많은 정보를 추가할 수 있다. 하지만 노출과 수정이 가능하기에 인증이 필요한 최소한의 정보만들 담아야한다. 
  • 서명(Signature)
    • 가장 중요한 부분으로 헤더와 정보를 합친 후 발급해준 서버가 지정한 Seret Key로 암호화 시켜 토큰을 변조하기 어렵게 만들어준다. 토큰이 발급된 후 누군가가 Payload의 정보를 수정하면 누군가의 조작된 정보가 들어가 있지만 Signature에는 수정되기 전의 Payload 내용 기반으로 이미 암호화 되어있기 때문에 조작된 토큰인지 아닌지 알 수 있다.

👩‍💻 Acess Token

  • 정의 : Access Token은 사용자가 인증된 후 서버가 클라이언트에게 발급하는 짧은 기간 동안 유효한 토큰입니다. 이 토큰은 API 요청을 인증하는 데 사용합니다.
  • 유효 기간 : 짧게 (15분, 30분)
  • 용도 : Access Token을 사용하여 사용자가 API를 요청할 때 마다 서버는 이 토큰을 확인하여 해당 사용자가 인증된 사용자임을 확인합니다.
  • 단점 : 짧은 유효 기간 때문에 사용자가 계속해서 서비스에 접근하려면 Access Token을 갱신할 수 있어야 한다.

👻 Refresh Token 

  • 정의 : Refresh Token은 Access Token의 유효 기간이 만료된 후 새로운 Access Token을 발급받기 위해 사용된는 길게 유효한 토큰입니다. 보통 서버에만 저장되며, 클라이언트는 주로 로컬 저장소나 쿠키에 저장합니다. 
  • 유효 기간 : 매우 길다 (몇 주나 몇달간 유효)
  • 용도 : 클라이언트 Access Token을 갱신할 때 사용됩니다. 예를 들어 Access Token이 만료되면 클라이언트는 Refresh Token을 서버에 보내 새로운 Access Token을 받급받을 수 있습니다. 

🥶 서버-클라이언트 통신 방법

  1. 사용자가 처음 로그인할 때 서버는 사용자 정보를 확인하고 Access Token과 Refresh Token을 발급해줍니다. 이때 Refresh Token은 DB에 저장합니다.
  2. 이제 사용자가 클라이언트에 요청할때 마다 HTTP 헤더에 Access Token을 포함시켜 보냅니다.
  3. 헤더에서 Access Token을 꺼내 검증한다.
    1. A ccess Token이 만료되었으면  Access Invalid Token이라는 메세지와 401상태값을 보낸다.
    2. Access Token이 없으면 Access Token is missing이라는 메세지와 401상태값을 보낸다.
  4. 만약 Access Token이 만료되었으면 사용자는 Refresh Token을 헤더에 담아서 보내고 클라이언트는 Refresh Token을 확인해 유효기간이 남았고 DB에도 똑같은 Token이 있으면 응답쿼리 헤더에 새로운 Access Token을 넣어 응답한다.
    1. Refresh Token도 만료 되었으면 Refresh Token has expired 이라는 메세지와 401상태값을 보낸다.
    2. DB에 저장된 Refresh Token이 서로 다르면 Refresh Token not found or invalid in DB 이라는 메세지와 401상태값을 보낸다.

🎃 토큰 생성 코드

아까 설명했던 JWT에서 Signature를 담당하는 부분이 SECRET_KEY입니다.

그리고 Payload로드에 정보를 추가 할 수 있는 방법이다. .claim("username", user.getUserName())이렇게 하면 Payload에 유저 이름도 포함 될 수가 있다. 위에서 설명 했듯이 Access Token은 짧은 유효 시간 을 가지고 Refresh Token은 긴 유효 시간을 가진다.

public static final String SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS256).getEncoded());
    public static final String REFRESH_TOKEN_SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS512).getEncoded());

    // accessToken 생성
    public static String createAccessToken(String userId) {

        return Jwts.builder()
                .setSubject(userId) // 유저 번호를 subject로 설정 // #G909
//                .claim("username", user.getUserName()) // 추가 정보 저장
                .setIssuedAt(new Date()) // 발급 시간 (현재 시간으로 자동 설정)
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 30)) // 30분 후 만료
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 서명
                .compact();
    }

    // Access Token 만료 시간 반환 메서드
    public static Date getExpirationFromToken(String accessToken) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY) // 서명 검증을 위한 키
                .parseClaimsJws(accessToken) // 토큰 파싱
                .getBody(); // Payload 추출

        return claims.getExpiration(); // 만료 시간 반환
    }

    // refresh Token 생성
    public static String createRefreshToken(String userId) {
        return Jwts.builder()
                .setSubject(userId)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7)) // 7일 후 만료
                .signWith(SignatureAlgorithm.HS512, REFRESH_TOKEN_SECRET_KEY)
                .compact();
    }

    // refresh Token 만료 시간 반환 메서드
    public static Date getExpirationFromRefreshToken(String accessToken) {
        Claims claims = Jwts.parser()
                .setSigningKey(REFRESH_TOKEN_SECRET_KEY)
                .parseClaimsJws(accessToken) 
                .getBody(); 

        return claims.getExpiration();
    }
public TokenVO makeToken(String userId) { // 토큰 생성 관련 메서드
        TokenVO token = new TokenVO();
        UserVO user = userRepository.finduserId(userId); // 존재하는 User인지 검증
        if(user == null) {return token;} // user가 없으면 null 보냄

        TokenVO vo = userRepository.findToken(userId); // DB에 토큰 검증

        String accessToken = jwtTokenProvider.createAccessToken(userId);
        String refreshToken = jwtTokenProvider.createRefreshToken(userId);

        Date expiration = jwtTokenProvider.getExpirationFromToken(refreshToken);
        // refreshToken 만료시간 형변환
        java.sql.Timestamp sqlExpiration = new java.sql.Timestamp(expiration.getTime());

        token.setUserId(userId);
        token.setAccessToken(accessToken);
        token.setRefreshToken(refreshToken);
        token.setExpirationTime(sqlExpiration);

        if(vo == null){ // 토큰이 없으면 INSERT
            userRepository.saveToken(token);
        } else { // 토큰이 있으면 UPDATE
            userRepository.saveUpdateToken(token);
        }
        return token;
    }

🔒 토큰 생성 결과

밑에 이미지 처럼 토큰이 생성되고 Refresh Token과 expirationTime은 나중에 Access Token이 만료되었을 때를 방지하기 위해 DB에 저장하였다.

🍻 Access Token 검증하기 

사용자가 클러이언트에 요청할때 마다 필터를 사용해서 헤더에서 Access Token을 꺼내서 검증을 합니다. 

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String requestURI = request.getRequestURI();
        // AntPathMatcher 으로 쉽게 동적 경로를 처리할 수 있다.
        AntPathMatcher pathMatcher = new AntPathMatcher();

        // 인증이 필요 없는 URL
        if (pathMatcher.match("/auth/refresh", requestURI) ||
            requestURI.startsWith("/public")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 헤더에서 토큰 추출
        String token = request.getHeader("Authorization");

        if (token == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Access Token is missing");
            return;
        }

        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7); // "Bearer " 부분 제거
            try {
                // 토큰 검증 및 만료 시간 확인
                Claims claims = Jwts.parser()
                        .setSigningKey(SECRET_KEY)
                        .parseClaimsJws(token)
                        .getBody();

                BlackTokenVO tokenVo = userRepository.checkBlackList(token); // 블랙리스트에 있는 토큰인지 검사
                if(tokenVo != null){ // 블랙리스트에 토큰이 있으면 보안이 위험
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("Danger Security Token!!");
                    return;
                }

                // 토큰이 유효한 경우 추가 작업 가능
                request.setAttribute("user", claims.getSubject());

            } catch (ExpiredJwtException e) { // Access Token 만료 시 발생
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Access Token Expired");
                return;

            }catch (Exception e) {
                log.error("Exception e = {}", e.getMessage());
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Access Invalid Token");
                return;
            }
        }

        // 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

 

💩 Refresh Token으로 Access Token 생성하기

Refresh Token을 검증해서 유효 기간이 유효하고 DB에있는 토큰이랑 똑같으면 응답쿼리 헤더에 새로운 Access Token을 만들어서 응답해줍니다.

@PostMapping("/auth/refresh")
    public ResponseEntity<?> refreshAccessToken(@RequestHeader("Authorization") String refreshToken) {
        Timestamp timestamp = new Timestamp(System.currentTimeMillis());
        log.info("date = {}", timestamp);
        userService.blackrest(timestamp); // 블랙 리스트 초기화

        try {
            if (!refreshToken.startsWith("Bearer ")) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body("Invalid Refresh Token format.");
            }
            refreshToken = refreshToken.substring(7);
            log.info("refreshToken = {}", refreshToken);

            // Refresh Token 검증
            Claims claims = Jwts.parser()
                    .setSigningKey(REFRESH_SECRET_KEY)
                    .parseClaimsJws(refreshToken)
                    .getBody();

            String userId = claims.getSubject();
            String storedToken = userService.findRefreshTokenByUserId(userId);

            if (storedToken == null || !storedToken.equals(refreshToken)) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body("Invalid Refresh Token.");
            }

            // 새 Access Token 발급
            String newAccessToken = Jwts.builder()
                    .setSubject(userId)
                    .setIssuedAt(new Date())
                    .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1시간 후 만료
                    .signWith(SignatureAlgorithm.HS512, REFRESH_SECRET_KEY)
                    .compact();

            log.info("newAccessToken = {}", newAccessToken);
            HttpHeaders headers = new HttpHeaders();
            headers.set("Authorization", "Bearer " + newAccessToken);

            return ResponseEntity.ok()
                    .headers(headers)
                    .body("Access Token refreshed successfully = " + newAccessToken);

        } catch (ExpiredJwtException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("Refresh Token has expired.");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("Invalid Refresh Token.");
        }
    }

🤭 내 생각 정리

JWT는 정보를 안전하게 전송하기 위해 사용되는 서명된 토큰입니다.

Access Token은 API 요청을 인증하는 짧은 기간 동안 유효한 토큰이며, Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받는 데 사용되는 길게 유효한 토큰입니다. 이 두 토큰을 함께 사용하면, 사용자는 로그인 상태를 유지하면서도 보안성을 높일 수 있습니다. Access Token과 Refresh Token은 기존의 인증 방식(예: 세션 기반 인증)에 비해 탈취 위험을 줄이고, 분산된 환경에서 효율적이며 확장 가능한 보안을 제공합니다.