Jwt Refresh Token 적용기

YoungHo-Cha·2022년 3월 2일
31

운동 매칭 시스템

목록 보기
5/17

오늘은 이전에 포스팅한 jwt 적용기의 2편이다.


목차

  • 문제인식
  • 해결방법
  • 구현

문제인식

Access Token을 적용하고 아주 큰? 문제를 발견했다.

보안 상으로 Access Token은 매우 짧은 만료기간을 가지고 있다. 그래서 사용자는 매번 만료가될 시 로그인을 새로 하여, 새롭게 Access Token을 받아야 한다는 것이다.

또한

사용자의 자동로그인에 문제가 생겼다.


해결방법

여러가지의 해결방법이 있다.

  1. Access Token의 만료기간을 매우매우 길게 설정해준다.
    • -> 보안 상 불가능
  2. Access Token을 매번 요청마다 새롭게 갱신한다.
    • -> 서버에 너무나 많은 요청을 하게 된다.
  3. Refresh Token을 도입한다.
    • 사실상 가장 괜찮은 기법

그럼 Refresh Token에 대해서 알아보자.


Refresh Token

간단하게, Access Token을 재발급 받기위한 Token이다.

OAuth2.0을 이용하여 타서비스 로그인 기능을 구현한 경험이 있다면, 누구나 들어보았을 것이다.

flow는 다음과 같이 움직인다.

  1. 클라이언트에서 로그인한다.

  2. 서버는 클라이언트에게 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장된다.

  3. 클라이언트는 local 저장소에 두 Token을 저장한다.

  4. 매 요청마다 Access Token을 헤더에 담아서 요청한다.

  5. 이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.

  6. 클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.

  7. 서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.

  8. 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 된다.

7번 과정에서 Refresh Token도 갱신하는 경우가 있다. 하지만 나는 갱신을 하지 않았다.

Token 설정

보통의 경우
Access Token 만료기간 : 30분 ~ 1시간
Refresh Token 만료기간 : 3일 ~ 1달으로 설정하는 듯 하다.

나는 Access Token은 30분, Refresh Token은 14일로 지정할 예정이다.


구현

구현하면서 임시로 log와 어노테이션을 생성하여 조금 지저분한 감이 있다..

JwtProvider.java 수정

createAccessToken 메소드 수정

가장 먼저, 토큰을 발급할 때 Access Token과 Refresh Token을 같이 발급하도록 하였다.

코드는 아래를 보자.

public Token createAccessToken(String userEmail, List<String> roles) {

        Claims claims = Jwts.claims().setSubject(userEmail); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();

        //Access Token
        String accessToken = Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, accessSecretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();

        //Refresh Token
        String refreshToken =  Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, refreshSecretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();




        return Token.builder().accessToken(accessToken).refreshToken(refreshToken).key(userEmail).build();



    }

코드를 살펴보면 token을 2개 생성한 모습을 볼 수 있다.

또한 Token은 TokenDTO의 역할로 만든 객체이다.

Token.java 생성

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Token {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private String key;

}

여기까지 진행되면, 2개의 토큰(Access, Refresh)가 생성되어 TokenDTO 객체로 반환하는 것을 볼 수 있다.

validateRefreshToken 메소드 생성

Refresh Token이 넘어왔을 때, 유효성 검증을 하는 메소드이다.

Access Token과 별개로 나둔 이유는 DB를 한번 거쳐야한다. 그래서 생성했다. (Access Token은 스프링 시큐리티 필터링 단계에서 해당 로직을 타기 때문에 별개로 나둬야 DB 접근 후 Refresh Token 객체에 맞는 유효성 검증 메소드를 탈 수 있다.)

public String validateRefreshToken(RefreshToken refreshTokenObj){


        // refresh 객체에서 refreshToken 추출
        String refreshToken = refreshTokenObj.getRefreshToken();


        try {
            // 검증
            Jws<Claims> claims = Jwts.parser().setSigningKey(refreshSecretKey).parseClaimsJws(refreshToken);

            //refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
            if (!claims.getBody().getExpiration().before(new Date())) {
                return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
            }
        }catch (Exception e) {

            //refresh 토큰이 만료되었을 경우, 로그인이 필요합니다.
            return null;

        }

        return null;
    }

recreationAccessToken 메소드 생성

해당 메소드는 Refresh Token을 받아 유효성 검증을 하는 메소드이다.
유효성 검증을 통과하게 되면, 새로운 Access Token을 생성하고 반환한다.


public String recreationAccessToken(String userEmail, Object roles){

        Claims claims = Jwts.claims().setSubject(userEmail); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();

        //Access Token
        String accessToken = Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, accessSecretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();

        return accessToken;
    }

Controller 수정

UserController.java

로그인 부분만 살펴보자.

// 로그인
    @PostMapping("/login")
    public Token login(@RequestBody Map<String, String> user) {
        log.info("user email = {}", user.get("userEmail"));
        User member = userRepository.findByUserEmail(user.get("userEmail"))
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
       
        Token tokenDto = jwtTokenProvider.createAccessToken(member.getUsername(), member.getRoles());
        log.info("getroleeeee = {}", member.getRoles());
        jwtService.login(tokenDto);
        return tokenDto;
    }

이전과는 다르게 TokenDTO 객체를 반환하는 것을 볼 수 있다.

RefreshController.java 생성

Refresh 관련 로직을 따로 다루기 위해서 Controller를 새롭게 만들었다.


@Slf4j
@RestController
@RequiredArgsConstructor
public class RefreshController {


    private final JwtService jwtService;

    @PostMapping("/refresh")
    public ResponseEntity<RefreshApiResponseMessage> validateRefreshToken(@RequestBody HashMap<String, String> bodyJson){

        log.info("refresh controller 실행");
        Map<String, String> map = jwtService.validateRefreshToken(bodyJson.get("refreshToken"));

        if(map.get("status").equals("402")){
            log.info("RefreshController - Refresh Token이 만료.");
            RefreshApiResponseMessage refreshApiResponseMessage = new RefreshApiResponseMessage(map);
            return new ResponseEntity<RefreshApiResponseMessage>(refreshApiResponseMessage, HttpStatus.UNAUTHORIZED);
        }

        log.info("RefreshController - Refresh Token이 유효.");
        RefreshApiResponseMessage refreshApiResponseMessage = new RefreshApiResponseMessage(map);
        return new ResponseEntity<RefreshApiResponseMessage>(refreshApiResponseMessage, HttpStatus.OK);

    }
}

/refresh 요청이 들어오면 바디의 Refresh Token을 받아 유효성 검증을 하게 된다.

그리고 Response 메세지를 생성해서 반환한다.

JwtService.java 생성

사실 생성인지 수정인지 기억안난다. ㅋㅋ

그럼 이제 해당 컨트롤러로 요청이 오게 되었을 때, 로직을 수행해줄 Service가 필요하다.

@Service
@RequiredArgsConstructor
@Slf4j
public class JwtService {

    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    @Transactional
    public void login(Token tokenDto){

        RefreshToken refreshToken = RefreshToken.builder().keyEmail(tokenDto.getKey()).refreshToken(tokenDto.getRefreshToken()).build();
        String loginUserEmail = refreshToken.getKeyEmail();
        if(refreshTokenRepository.existsByKeyEmail(loginUserEmail)){
            log.info("기존의 존재하는 refresh 토큰 삭제");
            refreshTokenRepository.deleteByKeyEmail(loginUserEmail);
        }
        refreshTokenRepository.save(refreshToken);

    }

    public Optional<RefreshToken> getRefreshToken(String refreshToken){

        return refreshTokenRepository.findByRefreshToken(refreshToken);
    }

    public Map<String, String> validateRefreshToken(String refreshToken){
        RefreshToken refreshToken1 = getRefreshToken(refreshToken).get();
        String createdAccessToken = jwtTokenProvider.validateRefreshToken(refreshToken1);

        return createRefreshJson(createdAccessToken);
    }

    public Map<String, String> createRefreshJson(String createdAccessToken){

        Map<String, String> map = new HashMap<>();
        if(createdAccessToken == null){

            map.put("errortype", "Forbidden");
            map.put("status", "402");
            map.put("message", "Refresh 토큰이 만료되었습니다. 로그인이 필요합니다.");


            return map;
        }
        //기존에 존재하는 accessToken 제거


        map.put("status", "200");
        map.put("message", "Refresh 토큰을 통한 Access Token 생성이 완료되었습니다.");
        map.put("accessToken", createdAccessToken);

        return map;


    }


}

에러 처리는 해놓았다.

RefreshToken.java 생성

Refresh Token을 DB에 저장해서 관리해보자. 나는 JPA를 이용한다.

@Builder
@Entity
@Getter
@Table(name = "T_REFRESH_TOKEN")
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "REFRESH_TOKEN_ID", nullable = false)
    private Long refreshTokenId;

    @Column(name = "REFRESH_TOKEN", nullable = false)
    private String refreshToken;

    @Column(name = "KEY_EMAIL", nullable = false)
    private String keyEmail;

}

RefreshTokenRepository.java 생성

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
   Optional<RefreshToken> findByRefreshToken(String refreshToken);
   boolean existsByKeyEmail(String userEmail);
   void deleteByKeyEmail(String userEmail);
}

이제 모든 준비가 완료되었다.
(참고로 해당 코드를 그대로 복사하면 당연히 돌아가지 않을 것이다. 커스텀한 메세지 클래스와 Exception들을 따로 설정한 것이 많다.)

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

2개의 댓글

comment-user-thumbnail
2023년 1월 29일

로컬에 둘다 저장하면 액세스 토큰이 탈취되는 문제점을 리프레쉬 토큰이 그대로 가질텐데 토큰 둘다를 로컬에다 저장해도 되나요?

1개의 답글