TIL

2023.12.07 TIL - Refresh Token 재발급 구현하기

Jwt를 이용한 토큰 인증방식으로 인증/인가를 구현하게 되면 장점도 있지만 단점도 존재한다.

 

RefreshToken

토큰 인증 방식은 보안에 취약하기 때문에, 보통 유효기간을 짧게 가져간다.

유효기간을 짧게 설정하면 로그인 요청이 많아지게 되어 토큰 방식으로 인증하는 의미가 퇴색된다.

 

이를 보완하기위해 Jwt AccessToken을 발급할 때, 유효기간을 길게 설정한 토큰인 RefreshToken도 발급하게 된다.

 

RefreshToken은 AccessToken과 마찬가지로 Jwt로 발급하거나 UUID를 활용한 긴 문자열로도 발급한다.

형태의 제한이 없고 식별할 수 있는 값이면 되는 것 같다.


 

인증 과정

토큰을 이용한 인증 과정은 다음과 같다.

 

 

1. 사용자 회원가입 정보 입력.
2. 유효한 데이터가 들어왔다면 회원가입 처리(DB 등록)
3. 사용자 정보와 권한이 들어가 있는 Access 토큰과 Refresh 토큰 발급

   (이때 Refresh 토큰은 인메모리 DB(Redis) , DB에 저장한다.)
4. 클라이언트는 두 종류의 토큰을 받는다. (AccessToken, RefreshToken)
5. 이후 사용자가 데이터를 요청할 때마다 Access 토큰을 세팅하여 보낸다.

6. 서버는 사용자로부터 전달된 Access 토큰이 유효한지만 판단한다(어자피 사용자의 권한과 정보는 토큰에 자체적으로 있다.)

7. Access 토큰이 유효하면 사용자의 요청을 처리해서 반환해준다.

 

RefreshToken은 여기서부터 필요하다.

- AccessToken이 만료된 경우

   1. 사용자는 만료된 Access 토큰으로 데이터 요청을 보낸다.

   2. 서버에서는 토큰에 대한 유효성 검사를 통해 만료된 토큰임을 확인한다.
   3. 클라이언트에 400, 401, 403 등의 응답을 보낸다. 
   4. 클라이언트는 Access 토큰 재발급을 위해 Access 토큰과 Refresh 토큰을 전송한다.
   5. 전달받은 Refresh 토큰이 그 자체로 유효한지 확인하고,

      3번에서 DB에 저장해 두었던 원본 Refresh 토큰과도 비교하여 같은지 확인   한다.

   6. 유효한 Refresh 토큰이면 Access 토큰을 재발급 해준다.
   7. 만약 Refresh 토큰도 만료됐다면 로그인을 다시하고 Access 토큰과 Refresh 토큰을 새로 발급해준다.

 

 

RefreshToken을 사용하게 되면

  • AccessToken의 유효기간을 짧게 설정할 수 있어 취약한 보안을 어느정도 보완할 수 있다.
  • Stateless 의 한계를 어느정도 보완해준다 - RefreshToken의 유효기간이 만료될 때 까지 추가적인 로그인 필요 X
  • 매 API 요청시 마다 DB를 조회하지 않고 토큰 자체만으로 사용자의 정보와 권한을 알 수 있다.

토큰 재발급 구현 

구현 코드는 MySQL DB에 저장해서 관리하는 코드입니다.

 

 

RefreshToken

package com.sparta.backoffice.auth.entity;

import com.sparta.backoffice.global.entity.BaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
   name = "refresh_token",
   indexes = @Index(name = "idx_refresh_token_refreshToken", columnList = "refreshToken")
)
@Entity
public class RefreshToken extends BaseEntity {
   @Id
   private String username;

   @Column
   private String token;

   public static RefreshToken of(String username, String token) {
      return new RefreshToken(username, token);
   }
}

 

AuthController

@Operation(summary = "토큰 재발급",
   description = "토큰 재발급 API",
   parameters = {@Parameter(name = "RefreshToken", description = "리프레쉬 토큰", in = ParameterIn.COOKIE)})
@ApiResponses(value = {
   @ApiResponse(
      responseCode = "201",
      description = "재발급 성공",
      headers = {
         @Header(name = "Authorization", description = "엑세스 토큰", required = true),
         @Header(
            name = "Set-Cookie",
            description = "RefreshToken",
            schema =
            @Schema(type = "String", name = "RefreshToken", description = "리프레쉬 토큰")
         )
      }
   ),
   @ApiResponse(
      responseCode = "400",
      description = "재발급 실패 - 유효하지 않은 토큰으로 요청 시 ",
      content = @Content(schema = @Schema(implementation = BaseResponse.class))
   ),
})
@PostMapping("/reissue")
public ResponseEntity<BaseResponse<String>> reissue(HttpServletRequest request, HttpServletResponse response) {
   authService.reissue(request, response);
   return ResponseEntity.status(REISSUE_TOKEN.getHttpStatus())
      .body(
         BaseResponse.of(
            REISSUE_TOKEN,
            ""
         )
      );
}

 

AuthService

@Transactional
public void reissue(HttpServletRequest request, HttpServletResponse response) {
   String targetToken = jwtProvider.getRefreshTokenFromCookie(request);

   //토큰 검증
   if (!jwtProvider.validateToken(targetToken)) {
      throw new ApiException(INVALID_TOKEN);
   }

   //토큰에서 username 추출
   Claims claims = jwtProvider.getUserInfoFromToken(targetToken);
   String username = claims.getSubject();
   UserRoleEnum role = UserRoleEnum.valueOf((String) claims.get("auth"));

   //db에서 refresh token 확인
   Optional<RefreshToken> refreshTokenOptional = refreshTokenRepository.findById(username);

   //refresh token 일치하는지 검증
   if (refreshTokenOptional.isEmpty()) {
      throw new ApiException(INVALID_TOKEN);
   }

   //토큰 재발급
   TokenDto tokenDto = jwtProvider.createToken(username, role);
   jwtProvider.setTokenResponse(tokenDto, response);

   //재발급된 토큰 정보 저장
   refreshTokenRepository.save(RefreshToken.of(username, tokenDto.getRefreshToken()));
}

 

//매일 0시 0분 0초 -> refresh 토큰 유효기간 지난 row 삭제
@Transactional
@Scheduled(cron="0 0 0 * * *")
public void refreshTokenSchedule() {
   LocalDateTime expireDate = LocalDateTime.now().minus(jwtProvider.getRefreshTokenExpiration(), ChronoUnit.MILLIS);
   refreshTokenRepository.deleteAllByModifiedAtBefore(expireDate);

   log.debug("delete refreshToken history -> today : {}", expireDate);
}
728x90