Spring Security + JWT 적용기 3편: 로그아웃에 이어지는 글입니다.
이번에는 Refresh Token으로 Access Token을 재발급하는 과정에 대해 정리해보려고 한다.
왜 Refresh Token을 사용할까?
로그인을 통해 JWT를 발급 받고 요청을 보낼 때 Access Token을 통해 토큰 인증을 진행한다. 그럼 왜 굳이 Refresh Token만들어서 관리하고 있는걸까?
만약 Access Token이 해커에게 탈취되면 해커는 탈취한 Access Token으로 API에 악의적으로 접근해 사용자 정보를 탈취하거나 변경할 수 있다.
이런 문제를 방지하기 위해 Access Token의 유효 시간을 짧게 유지하고, Refresh Token을 사용해서 Access Token이 만료될 때마다 자동으로 갱신할 수 있게 하는 것이 좋다.
Refresh Token 덕분에 Access Token이 만료되더라도 사용자는 로그인하지 않고도 API를 사용할 수 있는 것이다.
하지만 Refresh Token도 탈취될 수 있기 때문에 유효 시간을 두어야 한다. 다만, Access Token의 유효 시간보다는 길게 설정한다.
JWT 재발급 흐름
- 클라이언트가 서버에 HTTP 요청을 한다.
- 서버는 요청에서 Access Token 가져와 검증한다.
- Access Token이 만료되었을 경우 ExpiredJwtException 를 throw한다.
- 클라이언트는 서버에 Access Token 재발급을 요청한다.
- 클라이언트 요청에서 Refresh Token을 가져와 검증한다.
- Refresh Token이 유효하다고 검증이 되면 Access Token을 생성해 응답 헤더에 담아 전달한다.
- 만약 Refresh Token도 유효하지 않다면 클라이언트는 다시 로그인을 통해 JWT를 발급 받는다.
구현
AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider jwtTokenProvider;
@PatchMapping("/reissue")
public ResponseEntity reissue(HttpServletRequest request,
HttpServletResponse response) {
String encryptedRefreshToken = jwtTokenProvider.resolveRefreshToken(request);
String newAccessToken = authService.reissueAccessToken(encryptedRefreshToken);
jwtTokenProvider.accessTokenSetHeader(newAccessToken, response);
return new ResponseEntity<>(new SingleResponseDto<>(
"The access token was successfully reissued"), HttpStatus.OK);
}
}
AuthService
public interface AuthService {
String reissueAccessToken(String encryptedRefreshToken);
}
@Service
@Transactional
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final AES128Config aes128Config;
private final RedisService redisService;
private final MemberRepository memberRepository;
@Override
public String reissueAccessToken(String encryptedRefreshToken) {
this.verifiedRefreshToken(encryptedRefreshToken);
String refreshToken = aes128Config.decryptAes(encryptedRefreshToken);
Claims claims = jwtTokenProvider.parseClaims(refreshToken);
String email = claims.getSubject();
String redisRefreshToken = redisService.getValues(email);
if (redisService.checkExistsValue(redisRefreshToken) && refreshToken.equals(redisRefreshToken)) {
Member findMember = this.findMemberByEmail(email);
CustomUserDetails userDetails = CustomUserDetails.of(findMember);
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String newAccessToken = tokenDto.getAccessToken();
long refreshTokenExpirationMillis = jwtTokenProvider.getRefreshTokenExpirationMillis();
return newAccessToken;
} else throw new BusinessLogicException(ExceptionCode.TOKEN_IS_NOT_SAME);
}
...
private void verifiedRefreshToken(String encryptedRefreshToken) {
if (encryptedRefreshToken == null) {
throw new BusinessLogicException(ExceptionCode.HEADER_REFRESH_TOKEN_NOT_EXISTS);
}
}
private Member findMemberByEmail(String email) {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
}
}
토큰 재발급 로직 흐름에 대해 정리해보면
- 요청 헤더에서 꺼내온 refershToken이 null이 아닌지 확인한다.
- 존재한다면 aes128 복호화한 후 Claims 객체를 파싱해 온다.
- 파싱해 온 Claims 객체에서 Subject로 저장한 email을 가져온다.
- Redis에서 key를 email로 갖고 있는 refreshToken을 조회한다.
- redis에서 조회한 refreshToken과 요청으로 받은 refreshToken이 동일한지 검증한다.
- 동일하다면 Access Token을 생성해 응답 헤더에 담아 전달한다.
테스트
검증이 잘 진행되는지 통합 테스트를 해보자.
class AuthIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/auth";
private final String EMAIL = "email@gmail.com";
@Autowired
private MemberService memberService;
@Autowired
private RedisService redisService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void befroeEach() {
MemberDto.SignUp signUpDto = StubData.MockMember.getSignUpDto();
memberService.signUp(signUpDto);
}
@AfterEach
void afterEach() {
memberService.deleteMember(EMAIL);
}
@Test
@DisplayName("Access token 재발급 성공")
void accessTokenReissrueSuccessTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String refreshToken = tokenDto.getRefreshToken();
redisService.setValues(EMAIL, refreshToken, Duration.ofMillis(10000));
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.patchRequest(mvc, uri, encryptedRefreshToken);
// then
actions
.andExpect(status().isOk())
.andDo(document("access-token-reissue-success"));
}
@Test
@DisplayName("Refresh token 불일치로 Access token 재발급 실패")
void accessTokenReissrueFailTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String refreshToken = tokenDto.getRefreshToken();
String failRefreshToken = refreshToken + "fail";
redisService.setValues("email@gmail.com", failRefreshToken, Duration.ofMillis(10000));
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.patchRequest(mvc, uri, encryptedRefreshToken);
// then
actions
.andExpect(status().is(404))
.andDo(document("reissue-fail-by-token-not-same",
getResponsePreProcessor(),
getFieldErrorSnippetsLong()));
}
@Test
@DisplayName("Header에 Refresh token이 존재하지 않으면 Access token 재발급 실패")
void accessTokenReissrueFailTest2() throws Exception {
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/reissue")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.patchRequest(mvc, uri);
// then
actions
.andExpect(status().is(404))
.andDo(document("reissue-fail-by-no-refresh-token-in-header",
getResponsePreProcessor(),
getFieldErrorSnippetsLong()));
}
}
reissue가 성공, Refresh Token이 불일치로 인한 실패, Header에 Refresh Token이 존재하지 않아서 실패. 이렇게 3가지 케이스로 테스트를 진행했다.
테스트가 무사히 통과되었다!
위 통합 테스트에 대해 자세하게 정리한 내용은 아래 링크를 통해 확인할 수 있다.
마무리
Spring Security, JWT, Redis를 활용해서 Security를 구현했던 과정을 모두 정리했다. 느리더라도 차근차근 정리하면서 정리한 덕에 Security 흐름이 어느정도 이해가 되는 것 같다.
Security가 강화될수록 시스템 복잡도와 사용자 경험이 떨어진다고 느꼈는데, 보안과 사용자 경험 사이에 적절한 밸런스를 맞추는 것이 중요할 것 같다.
'Spring' 카테고리의 다른 글
Spring - 로컬 환경을 위한 Embedded Redis 적용하기 (+ Can't start redis server. Check logs for details) (3) | 2023.04.15 |
---|---|
Spring - Redis를 사용해보자 (2) | 2023.04.15 |
Spring - Spring Security + JWT 적용기 3편: 로그아웃 (3) | 2023.04.14 |
Spring - Spring Security + JWT 적용기 2편: JWT 검증 (0) | 2023.04.14 |
Spring - Spring Security + JWT 적용기 1편: 로그인 (5) | 2023.04.14 |