이전 글에서 정리한 JWT 토큰을 보관하는 방법 중 accessToken을 로컬 변수에 저장하고, refreshToken만 쿠키에 저장하는 방법으로 직접 구현해보려고 한다.
간단하게 구현방법을 요약해보면 다음과 같다.
secure httpOnly 플래그 설정이 된 쿠키에 저장한다secure httpOnly 플래그 설정이 된 쿠키에 저장되어 있기 때문에 javascript(XSS)로 데이터를 탈취할 수 없음✅ 로그인
secure httpOnly 쿠키에 담고, accessToken을 response body에 담아 반환한다✅ accessToken이 사라졌을 때 & accessToken이 만료되었을 때
secure httpOnly가 설정된 cookie에 refresh token을 담고, response body에 accessToken을 담아 반환/refresh 과 같은 엔드포인트를 만듦 - 여기서는 사용자 요청 쿠키에서 refresh token을 꺼내 검증 후 새로운 refresh token을 쿠키에 설정하고 body로 새로운 accessToken을 반환/refresh 엔드포인트에 refreshToken과 accessToken 재발급 요청@PostMapping("/login")
public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
    User user = userService.login(loginRequest);
    //user 테이블에 저장된 refreshToken 업데이트
    String refreshToken = JwtUtil.createJwt(secretKey, expiredRefreshMs, user.getEmail());
    user.updateRefreshToken(refreshToken);
    userService.updateRefreshToken(user);
    //`secure` `httpOnly`가 설정된 cookie에 refresh token을 담음
    Cookie cookie = new Cookie("refresh-token", refreshToken);
    cookie.setPath("/");
    cookie.setHttpOnly(true);
    cookie.setSecure(true);
    response.addCookie(cookie);
    // response body에 accessToken을 담아 반환
    String accessToken = JwtUtil.createJwt(secretKey, expiredJwtMs, user.getEmail());
    return ResponseEntity.ok(new AuthTokenResponse(accessToken));
}
@GetMapping("/refresh")
public ResponseEntity<AccessTokenResponse> getAccessToken(HttpServletRequest request) {
    //사용자 요청 쿠키에서 refresh token을 꺼내 검증 후 새로운 refresh token을 쿠키에 설정하고 body로 새로운 accessToken을 반환
    String refreshToken = getRefreshToken(request);
    String memberEmail = JwtUtil.getMemberEmail(refreshToken, secretKey);
    String accessToken = JwtUtil.createJwt(secretKey, expiredJwtMs, memberEmail);
    return ResponseEntity.ok(new AccessTokenResponse(accessToken));
}
@GetMapping("/test")
public ResponseEntity<String> getInfo(@RequestHeader HttpHeaders header) {
	//클라이언트 API 요청이 오면 Authrization header에 담긴 accessToken의 유효성을 검사
    validateAccessToken(header);
    return ResponseEntity.ok("hi lime!");
}
private void validateAccessToken(HttpHeaders header) {
    String accessToken = header.getFirst("Authorization");
    //만약 유효하지 않은 토큰이 오면 401을 반환
    if (accessToken == null || JwtUtil.isExpired(accessToken, secretKey))
        throw new UnAuthorizationException("유효하지 않은 토큰");
}
async login() {
  axios.defaults.withCredentials = true;
  try {
    const response = await axios.post('http://localhost:80/login', {
      id: this.username,
      password: this.password
    });
    if (response.status == 200) {
      const { accessToken } = response.data;
      
      //엑세스 토큰을 axios headers 기본값으로 설정(로컬 변수)
      axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
      this.$router.push('/');
    } else {
      this.errorMessage = 'Invalid username or password';
    }
  } catch (error) {
    this.errorMessage = '에러발생!';
    console.error(error);
  }
}
async apiCall() {
  axios.defaults.withCredentials = true;
  //accessToken이 없다면 refreshToken으로 accessToken 갱신
  if (!axios.defaults.headers.common['Authorization']) {
    console.log("accessToken이 사라짐, refreshToken으로 재발급")
    await this.refreshAccessToken();
  }
  await this.fetchData();
}
async refreshAccessToken() {
  try {
    const response = await axios.get('http://localhost:80/refresh');
    if (response.status === 200) {
      const { accessToken } = response.data;
      axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
      console.log("성공");
      return true;
    } else {
      console.log('리프레쉬 토큰이 없거나 만료됨');
      return false;
    }
  } catch (error) {
    console.log('리프레쉬 토큰을 가져오는 중 오류 발생');
    return false;
  }
}
async fetchData() {
  try {
    console.log("/test요청")
    const response = await axios.get('http://localhost:80/test');
    this.servermsg = response.data;
  } catch (error) {
    //accessToken이 만료되었을 때 리프레쉬 토큰으로 accessToken 재발급
    if (error.response && error.response.status === 401) {
      console.log("접근 토큰이 만료됨, 리프레쉬 토큰 사용 시도");
      if (await this.refreshAccessToken()) {
        const response = await axios.get('http://localhost:80/test');
        this.servermsg = response.data;
      } else {
        console.log('리프레쉬 토큰 만료, 로그인 필요');
        this.$router.push('/about');
      }
    } else {
      console.log('로그인 필요');
      this.$router.push('/about');
    }
  }
}
프론트엔드 지식이 부족해서 로컬변수에 어떻게 깔끔하게 저장해야하는지 몰랐는데, 직접 구현해보니 어떻게 로직이 돌아가는지 확실히 이해할 수 있었다.
역시 직접 만들어보는게 도움이 많이 되는 것 같다.