관리 메뉴

거니의 velog

(31) 스프링 시큐리티와 API 서버 5 본문

SpringBoot_React 풀스택 프로젝트

(31) 스프링 시큐리티와 API 서버 5

Unlimited00 2024. 3. 6. 16:10

5. Refresh Token

* Access Token은 일반적으로 짧은 유효시간을 지정해서 탈취당하더라도 위험을 줄일 수 있도록 구성한다. 그 때문에 일반적으로 Access Token이 만료되면 사용자는 Refresh Token을 활용해서 새로운 Access Token을 발급받을 수 있는 기능을 같이 사용하는 경우가 많다.
* 프로젝트에서는 /api/member/refresh 라는 경로를 통해서 Access Token 과 Refrech Token 을 검증하고 Access Token이 만료되었고, Refresh Token이 만료되지 않았다면 새로운 Access Token을 전송해 주는 기능을 구현한다. 
* 구현하려는 기능은 다음과 같은 조건들을 만족시켜야 한다.

- Access Token 이 없거나 잘못된 JWT인 경우 -> 예외 메시지 발생

- Access Token 의 유효기간이 남아 있는 경우 -> 전달된 토큰들을 그대로 전송

- Access Token 은 만료, Refresh Token 은 만료되지 않은 경우 -> 새로운 Access Token

- Refresh Token 의 유효기한이 얼마 남지 않은 경우 -> 새로운 Refresh Token

- Refresh Token 의 유효기한이 충분히 남은 경우 -> 기존의 Refresh Token

* 이 조건 중에서 Access Token 의 유효기간이 남아 있는 경우는 다시 발행해야 하는 조건에 해당하지 않기 때문에 별도의 처리 없이 전달받은 Access Token 과 Refrech Token 을 다시 반환하도록 한다.


(1) Refresh Token 의 발행

* 프로젝트의 controller 패키지에 APIRefreshController를 추가하고 Access Token과 Refresh Token을 파라미터로 처리하는 기능을 구현한다.

package com.unlimited.mallapi.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.unlimited.mallapi.util.CustomJWTException;
import com.unlimited.mallapi.util.JWTUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
public class APIRefreshController {

    /**
     * Refresh 토큰을 이용하여 새로운 Access 토큰과 Refresh 토큰을 발급하는 엔드포인트
     *
     * @param authHeader   Authorization 헤더
     * @param refreshToken Refresh 토큰
     * @return 새로운 Access 토큰과 Refresh 토큰을 담은 Map
     */
    @RequestMapping("/api/member/refresh")
    public Map<String, Object> refresh(@RequestHeader("Authorization") String authHeader, String refreshToken) {

        // Refresh 토큰이 null인 경우 예외 처리
        if (refreshToken == null) {
            throw new CustomJWTException("NULL_REFRASH");
        }

        // Authorization 헤더가 null이거나 형식에 맞지 않는 경우 예외 처리
        if (authHeader == null || authHeader.length() < 7) {
            throw new CustomJWTException("INVALID_STRING");
        }

        // Authorization 헤더에서 Access 토큰 추출
        String accessToken = authHeader.substring(7);

        // Access 토큰이 만료되지 않았다면 현재 토큰 반환
        if (!checkExpiredToken(accessToken)) {
            return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
        }

        // Refresh 토큰 검증 및 클레임 추출
        Map<String, Object> claims = JWTUtil.validateToken(refreshToken);

        log.info("refresh ... claims: " + claims);

        // 새로운 Access 토큰 및 Refresh 토큰 발급
        String newAccessToken = JWTUtil.generateToken(claims, 10);
        String newRefreshToken = checkTime((Integer) claims.get("exp")) ? JWTUtil.generateToken(claims, 60 * 24)
                : refreshToken;

        return Map.of("accessToken", newAccessToken, "refreshToken", newRefreshToken);
    }

    /**
     * 토큰의 만료 시간이 1시간 미만인지 확인
     *
     * @param exp 토큰의 만료 시간
     * @return 1시간 미만이면 true, 그렇지 않으면 false
     */
    private boolean checkTime(Integer exp) {
        // JWT exp를 날짜로 변환
        java.util.Date expDate = new java.util.Date((long) exp * 1000);

        // 현재 시간과의 차이 계산 - 밀리세컨즈
        long gap = expDate.getTime() - System.currentTimeMillis();

        // 분 단위 계산
        long leftMin = gap / (1000 * 60);

        // 1시간도 안 남았는지 확인
        return leftMin < 60;
    }

    /**
     * 토큰이 만료되었는지 확인
     *
     * @param token 확인할 토큰
     * @return 만료되었으면 true, 그렇지 않으면 false
     */
    private boolean checkExpiredToken(String token) {
        try {
            // 토큰 검증
            JWTUtil.validateToken(token);
        } catch (CustomJWTException ex) {
            // 만료되었을 경우 true 반환
            if (ex.getMessage().equals("Expired")) {
                return true;
            }
        }
        // 만료되지 않았을 경우 false 반환
        return false;
    }
}

* APIRefreshController는 문제가 발생하면 CustomJWTException을 반환하므로 controller/advice 패키지의 CustomControllerAdvice를 사용해서 예외 발생시 JSON 문자열을 전송하도록 구성한다.

package com.unlimited.mallapi.controller.advice;

import java.util.Map;
import java.util.NoSuchElementException;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.unlimited.mallapi.util.CustomJWTException;

@RestControllerAdvice
public class CustomControllerAdvice {

    @ExceptionHandler(NoSuchElementException.class)
    protected ResponseEntity<?> notExist(NoSuchElementException e) {
        String msg = e.getMessage();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("mag", msg));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<?> handleIllegalArgumentException(MethodArgumentNotValidException e) {
        String msg = e.getMessage();
        return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(Map.of("msg", msg));
    }

    @ExceptionHandler(CustomJWTException.class)
    protected ResponseEntity<?> handleJWTException(CustomJWTException e) {
        String msg = e.getMessage();
        return ResponseEntity.ok().body(Map.of("error", msg));
    }

}

[Refresh Token 발행 테스트]

* /api/member/refresh 경로로 동작하는 APIRefreshController는 만료된 AccessToken과 만료되지 않은 Refresh Token이 필요하다. 이를 테스트하기 위해서 /api/member/login 에서 user1@aaa.com/1111 로 로그인 테스트를 먼저 진행한다.

* 화면에서 생성된 accessToken과 refreshToken으로 /api/member/refresh 경로를 호출한다. Access Token은 이전과 마찬가지로 Authorization 헤더로 'Bearer 토큰' 의 형태로 지정하고, Refresh Token은 refreshToken이라는 파라미터로 전달한다. 전달하는 방식은 GET/POST 모두 가능하다.

Access Token 지정
Refresh Token 지정

* 테스트 결과를 시간에 따라서 확인해 보면 Access Token이 만료된 후에는 새로운 Access Token이 생성되는 것을 확인할 수 있고, Refresh Token은 유효시간이 1일이므로 그대로 전송되는 것을 볼 수 있다.

일단 결과가 떠야 정상

* 만일 Access Token과 Refresh Token이 모두 만료되었다면 애플리케이션에서는 완전히 새로 로그인을 해야 하는 상황이므로 'Expired' 라는 메시지가 전송된다.


(2) 애플리케이션에서의 시나리오

* 토큰 기반의 인증 방식은 API 서버와 독립된 애플리케이션 간의 인증 수단으로 사용된다. 예제에서는 리액트 애플리케이션에서 Access Token과 Refresh Token으로 API 서버를 호출하고 결과를 사용하는 방식이다. 이를 이용하는 시나리오를 정리해 보면 다음과 같다.

- 애플리케이션에서는 /api/member/login 을 수행해서 사용자의 정보와 발행된 Access Token/Refrech Token을 전달받는다.

- 이후 API 서버의 보호된 자원(예제에서는 /api/todos/... 혹은 /api/products/...) 를 호출할 때 Access Token을 
  Authorization 헤더값으로 전달해 주어야 한다.
  
- API 서버에서는 JWTCheckFilter를 이용해서 Access Token에 대한 검증을 하는데 Access Token은 유효시간이 짧기 때문에
  (예제에서는 10분) 시간이 조금만 지나면 {error: 'Expired'} 메시지만 전송된다.
  
- Access Token이 만료되었다면 애플리케이션은 가지고 있는 Refresh Token을 이용해서 /api/member/refresh를 호출한다.

- Refresh Token이 만료되지 않았다면 서버에서는 새로운 Access Token과 Refresh Token을 전송한다
  (이 때, Refresh Token이 1시간 미만으로 남았다면 새로운 Refresh Token도 전송해 준다)
  
- 만일 Access Token과 Refresh Token 모두 만료되었다면 사용자는 새로 로그인을 해야만 한다.