JWT를 안전하게 보관하기1 (accessToken을 로컬변수에 저장, refreshToken을 쿠키에 저장)

mylime·2024년 5월 26일
1

JWT

목록 보기
2/3
post-thumbnail

이전 글에서 정리한 JWT 토큰을 보관하는 방법 중 accessToken을 로컬 변수에 저장하고, refreshToken만 쿠키에 저장하는 방법으로 직접 구현해보려고 한다.


간단하게 구현방법을 요약해보면 다음과 같다.

  1. accessToken은 클라이언트 로컬변수에 저장하고
  2. refreshToken은 secure httpOnly 플래그 설정이 된 쿠키에 저장한다



이 방식의 장점


  • accessToken을 localStorage, cookie 어디에도 저장하지 않기 때문에 공격자가 javascript(XSS)로 데이터를 탈취할 수 없음
  • refreshToken 또한 secure httpOnly 플래그 설정이 된 쿠키에 저장되어 있기 때문에 javascript(XSS)로 데이터를 탈취할 수 없음
  • refreshToken만을 secure HttpOnly 쿠키에 저장함으로써 CSRF공격도 방어할 수 있다. 만약 refresh token이 CSRF에 의해 사용된다 하더라도 accessToken body는 사용자에게 반환되기 때문에 공격자는 accessToken을 알 수 없음



구현방법 설계


✅ 로그인

  • 유저가 로그인을 완료하면 백엔드에서는 refresh Token을 secure httpOnly 쿠키에 담고, accessToken을 response body에 담아 반환한다
  • 프론트엔드는 accessToken을 로컬 변수에 저장, 서버에 API 요청시마다 accessToken을 Authorization 헤더에 넣어 보냄

✅ accessToken이 사라졌을 때 & accessToken이 만료되었을 때

  • accessToken은 로컬 변수에 담겨있기 때문에 유저가 새로고침 하거나 탭을 전환 시 accessToken이 사라짐
  • accessToken이 빈값이거나, accessToken 요청 시 만료되었다는 응답을 받았을 때 브라우저에서는 새로운 accessToken 발급받는 request를 서버에 보냄(쿠키에 저장되어있기 때문에 자동으로 보내진다)
  • 서버는 새로운 refreshToken과 accessToken을 클라이언트에게 보냄
  • 클라이언트는 해당 accessToken을 로컬변수에 저장, 다시 reqeust를 보냄

🐬 서버 구현

  • 로그인 완료 시 secure httpOnly가 설정된 cookie에 refresh token을 담고, response body에 accessToken을 담아 반환
  • 클라이언트 API 요청이 오면 Authrization header에 담긴 accessToken의 유효기간 등 유효성을 검사하여 응답
  • 만약 유효하지 않은 토큰이 오면 401을 반환
  • /refresh 과 같은 엔드포인트를 만듦 - 여기서는 사용자 요청 쿠키에서 refresh token을 꺼내 검증 후 새로운 refresh token을 쿠키에 설정하고 body로 새로운 accessToken을 반환

🐥 클라이언트 구현

  • 로그인 요청 후 body로 받은 accessToken을 로컬 변수에 저장, 해당 변수는 axios 요청 시마다 Authorization 헤더에 설정하여 같이 보냄
  • 만약 사용자의 새로고침 등의 이슈로 accessToken이 사라지거나, accessToken의 유효기간이 만료되었다는 응답이 오면 /refresh 엔드포인트에 refreshToken과 accessToken 재발급 요청
  • 응답으로 받은 accessToken을 로컬 변수에 다시 저장, 반복

서버 코드


1. 로그인

@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));
}

2. 리프레쉬 토큰으로 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));
}

3. 그 외 API 요청

@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("유효하지 않은 토큰");
}



클라이언트 코드


1. 로그인

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);
  }
}

2. API요청 + Refresh Token으로 AccessToken 갱신

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();
}
  • api 요청 전 accessToken값이 있다면 그대로 요청
  • accessToken값이 없다면 refreshToken으로 accessToken 재요청
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;
  }
}
  • cookie에 저장된 refresh Token 값으로 accessToken을 갱신하는 api요청을 보냄
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');
    }
  }
}
  • accessToken을 담아 api요청
  • accessToken이 만료되었으면 cookie에 저장된 refresh Token값으로 accessToken을 갱신하는 api 요청을 보냄



마치며..


프론트엔드 지식이 부족해서 로컬변수에 어떻게 깔끔하게 저장해야하는지 몰랐는데, 직접 구현해보니 어떻게 로직이 돌아가는지 확실히 이해할 수 있었다.
역시 직접 만들어보는게 도움이 많이 되는 것 같다.



참고자료


profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

0개의 댓글