이전에 구현했던 JWT 로그인에 Refresh Token을 추가해보았다.
전체 코드는 여기에 올렸다.
1. Refresh Token이란
지난 포스팅에선 Access 토큰만 사용하여 인터셉터가 적용된 자원에 접근해보았다.
하지만 이 Access 토큰을 사용하여 요청과 응답을 받는 과정에서 우리는 이 토큰을 탈취당할 수 있다.
토큰은 [헤더]+[페이로드]+[비밀키]로 암호화 되어있지만 성능 좋은 컴퓨터로 비밀키를 알아내면 우리는 모든 정보와 권한을 빼앗기에 된다.
이를 보완하고자 Refresh 토큰의 개념이 사용된다.
- Access 토큰의 유효기간은 매우 짧게
- Refresh 토큰의 유효기간은 길게
해주는 것이 포인트다.
사용자는 Access 토큰을 통해서만 자원에 접근이 가능하고 Refresh 토큰은 소용이 없다.
그럼 언제 Refresh 토큰을 사용하는가?
유효기간이 짧은 Access 토큰이 만료가 되면 Refresh 토큰을 확인하여 검증 후 Access 토큰을 재발급해준다.
즉,
- Access 토큰을 통해서만 자원에 접근이 가능, But 유효기간이 매우 짧다(탈취를 당해도 이미 사용할 수 없는 상태)
- Refresh 토큰은 유효기간이 길기에 탈취당할 수도 있지만 Refresh 토큰은 오직 Access 토큰을 재발급하는 용도(Refresh 토큰 자체로는 별 쓸모가 없다.)
2. 인증 과정
위 사진은 로그인으로 설명이 돼있는데 회원가입으로 설명하는 것이 흐름을 알아보기에 조금 더 좋을 것 같아서 조금 바꿔서 설명하겠다.
- 사용자 회원가입 정보 입력.
- 유효한 데이터가 들어왔다면 회원가입 처리(DB 등록)
- 사용자 정보와 권한이 들어가 있는 Access 토큰과 Refresh 토큰 발급 (이때 Refresh 토큰은 DB에 저장한다.)
- 클라이언트는 두 종류의 토큰을 받는다.
- 이후 사용자가 데이터를 요청할 때마다 Access 토큰을 동봉하여 보낸다.
- 서버는 사용자로부터 전달된 Access 토큰이 유효한지만 판단한다(어자피 사용자의 권한과 정보는 토큰에 자체적으로 있다.)
- Access 토큰이 유효하면 사용자의 요청을 처리해서 반환해준다.
- 이때, 유효기간이 짧은 Access 토큰이 만료됐다고 해보자.
- 사용자는 만료된 Access 토큰으로 데이터 요청을 보낸다.
- 서버에서는 토큰에 대한 유효성 검사를 통해 만료된 토큰임을 확인한다.
- 클라이언트에게 "너의 토큰은 만료되었으니 갱신하기위해 Refresh 토큰을 보내라" 라고 응답한다.
- 클라이언트는 Access 토큰 재발급을 위해 Access 토큰과 Refresh 토큰을 전송한다.
- 전달받은 Refresh 토큰이 그 자체로 유효한지 확인하고, 3번에서 DB에 저장해 두었던 원본 Refresh 토큰과도 비교하여 같은지 확인한다.
- 유효한 Refresh 토큰이면 Access 토큰을 재발급 해준다.
- 만약 Refresh 토큰도 만료됐다면 로그인을 다시하고 Access 토큰과 Refresh 토큰을 새로 발급해준다.
더 간단한 흐름도로 확인하면 다음과 같다.
Refresh 토큰을 통해
- Access 토큰의 유효기간을 짧게 잡을 수 있다.(탈취 방지)
- Access 토큰의 유효기간에 짧음에도 불구하고 Refresh 토큰(유효기간 김)이 만료될때까지 추가적인 로그인을 안해도 된다. 마치 세션이 유지되는 것 처럼.
또 이러한 JWT 방식은
- 서버가 다수 존재하는 환경에서 유용하다. 세션을 사용하면 모든 서버에서 세션 내용을 공유해야 하기 때문
- 또한 매 요청시마다 DB 조회를 안하고 토큰 자체만으로 사용자의 정보와 권한을 알 수 있기에 병목현상을 방지한다.
3. 구현
구조
우리의 목표는 이 API에 접근하는 것
@PostMapping("/user/test")
public Map userResponseTest() {
Map<String, String> result = new HashMap<>();
result.put("result","success");
return result;
}
이는 인터셉터로 막혀있다
@Component
@RequiredArgsConstructor
public class JwtTokenInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
System.out.println("JwtToken 호출");
String accessToken = request.getHeader("ACCESS_TOKEN");
System.out.println("AccessToken:" + accessToken);
String refreshToken = request.getHeader("REFRESH_TOKEN");
System.out.println("RefreshToken:" + refreshToken);
if (accessToken != null && jwtTokenProvider.isValidAccessToken(accessToken)) {
return true;
}
response.setStatus(401);
response.setHeader("ACCESS_TOKEN", accessToken);
response.setHeader("REFRESH_TOKEN", refreshToken);
response.setHeader("msg", "Check the tokens.");
return false;
}
}
회원가입을 하면 Access 토큰과 Refresh 토큰 발행
// JWT 토큰 생성
public String createAccessToken(String userId) {
Claims claims = Jwts.claims();// JWT payload 에 저장되는 정보단위
claims.put("userId", userId);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) // 만료시간 설정
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
클라이언트에서 이렇게 토큰을 받는다.
Access 토큰을 통해 데이터 요청
잘 받아졌다.
시간이 지나 Access 토큰이 만료되면
아무 응답도 받지 못함과 동시에 토큰이 만료됐음을 확인할 수 있다.
Refresh 토큰을 사용하여 Access 토큰 재발급
public TokenResponse issueAccessToken(HttpServletRequest request){
String accessToken = jwtTokenProvider.resolveAccessToken(request);
String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
//accessToken이 만료됐고 refreshToken이 맞으면 accessToken을 새로 발급
if(!jwtTokenProvider.isValidAccessToken(accessToken)){ //클라이언트에서 토큰 재발급 api로의 요청을 확정해주면 이 조건문은 필요 없을 것 같다.
System.out.println("Access 토큰 만료됨");
if(jwtTokenProvider.isValidRefreshToken(refreshToken)){ //들어온 Refresh 토큰이 자체적으로 유효한지
System.out.println("Refresh 토큰은 유효함");
Claims claimsToken = jwtTokenProvider.getClaimsToken(refreshToken);
String userId = (String)claimsToken.get("userId");
Optional<User> user = userRepository.findByUserId(userId);
String tokenFromDB = authRepository.findByUserId(user.get().getId()).get().getRefreshToken();
System.out.println("tokenFromDB = " + tokenFromDB);
if(refreshToken.equals(tokenFromDB)) { //DB의 원본 refresh토큰과 지금들어온 토큰이 같은지 확인
System.out.println("Access 토큰 재발급 완료");
accessToken = jwtTokenProvider.createAccessToken(userId);
}
else{
//DB의 Refresh토큰과 들어온 Refresh토큰이 다르면 중간에 변조된 것임
//예외발생
System.out.println("Refresh Token Tampered");
}
}
else{
//입력으로 들어온 Refresh 토큰이 유효하지 않음
}
}
return TokenResponse.builder()
.ACCESS_TOKEN(accessToken)
.REFRESH_TOKEN(refreshToken)
.build();
}
Refresh 토큰을 사용하여 Access 토큰을 재발급하는 메소드인데 뭔가 내용이 많아보이지만 사실상 간단하다.
- Access 토큰이 만료됐다면
- Refresh 토큰은 유효하다면
- DB에 저장돼있는 Refresh 토큰 원본과 지금 들어온 Refresh 토큰이 일치한다면
Access 토큰을 재발급 해주는 흐름이다.
또, 이 글을 작성하면서 알게된 사실인데 Refresh 토큰의 페이로드에는 사용자 정보를 넣지 않는게 좋다고 한다. 생각해 보니 그런 것 같다. Refresh 토큰은 유효기간이 길기에 탈취될 수 있고(그 자체로는 뭘 할순 없지만) 탈취되어 내용을 까보면 치명적이진 않더라도 사용자의 정보가 노출되어 버리기 때문인 것 같다. 수정해야겠다.
4. Refresh 토큰에 대한 고찰
로그인이나 회원가입을 하고 Refresh 토큰을 클라이언트에게 반환하는 것을 보안상 문제로 인해 지양해야 한다는 주장이 있다. 카카오는 작년 업데이트를 통해 Javascript를 통한 카카오 로그인 기능에서 response 값에서 Refresh 토큰을 제외하기로 했다.
https://devtalk.kakao.com/t/javascript-api-sdk-refresh-token/105942
현재 나의 자그마한 뇌로는 구체적으로 어떻게 위협이 될 수 있는지 잘 모르겠다만 아무래도 민감한 정보를 클라이언트에 노출하는 것이다보니 이런 결정을 한 것 같다.
이 뿐만 아니더라도 JWT에 대한 정보를 찾다보면 클라이언트로 발급한 Refresh 토큰을 어디에 저장해야 하는지에 대한 고민을 가진 사람들이 많았다.
그 중 어떤 분이 Refresh 토큰을 토큰 자체로 반환하지 말고 DB에 저장된 곳의 인덱스(정수 or 해시값)만 반환한다면, 클라이언트 측에서는 무의미한 인덱스 숫자만 알게 되는 것이기에 보안적으로 조금 더 좋다는 의견을 제시했다.
상당히 멋진 아이디어라고 생각됐기에 나름대로 구현을 시도해보았다.
결과부터 말하면 실패.
//refresh 토큰의 인덱스를 통한 검증
public TokenIndexResponse issueAccessIndexToken(HttpServletRequest request){
String accessToken = jwtTokenProvider.resolveAccessToken(request);
Long refreshTokenIndex = jwtTokenProvider.resolveRefreshIndexToken(request);
if(jwtTokenProvider.isOnlyExpiredToken(accessToken)) { //만료만 된 토큰이라면
Optional<Auth> findAuth = authRepository.findById(refreshTokenIndex);
String refreshTokenFromDB = findAuth.get().getRefreshToken();
Claims claimsToken = jwtTokenProvider.getClaimsToken(refreshTokenFromDB);
String userId = (String)claimsToken.get("userId");
if (jwtTokenProvider.isValidRefreshToken(refreshTokenFromDB)) {
System.out.println("Access 토큰 재발급 완료 by 인덱스");
accessToken = jwtTokenProvider.createAccessToken(userId);
} else {
//예외발생
System.out.println("Refresh Token Tampered");
}
}
return TokenIndexResponse.builder()
.ACCESS_TOKEN(accessToken)
.REFRESH_TOKEN_INDEX(refreshTokenIndex)
.build();
}
위에서 Refresh 토큰의 페이로드에는 사용자 정보를 담지 않는게 좋다고 했는데
Refresh 토큰 대신 인덱스를 반환하는 이 방식에서는 Refresh 토큰은 DB상에만 존재하게 되니까 상관없을 것 같다.
저 코드대로 해보면 되긴 된다. 근데 이제 매우 안전하지 않다는게 문제다.
구체적으론, 어떤 사용자가 요청한 것인지 확신을 못한다.
Attempt 1
- 요청은 Access 토큰과 Refresh 토큰의 id(인덱스) 값
- Access 토큰이 Expire만 됐고 다른 정보는 유효하다면, Refresh 토큰의 인덱스를 통해 Refresh 토큰 조회 후 Access 토큰 재발급
But,
- 다른 사람의 만료된 Access 토큰과 아무 인덱스 숫자를 요청하고 운좋게 해당 인덱스에 누군가의 Refresh 토큰이 들어있었다면 그 사람의 권한을 가진 Access 토큰이 제 3자에게 발급돼 버린다.
Attempt 2
- Refresh 토큰을 저장하는 Auth 테이블에 원래 Refresh 토큰 필드만 있었는데 Access 토큰 필드도 추가해 준다.
- Auth 테이블에는 [id], [Access 토큰], [Refresh 토큰] 이렇게 3개의 필드가 있는 것.
- 요청받은 Access 토큰(만료된거)과 Refresh토큰의 인덱스번호가 동시에 일치하는 경우에만 Access 토큰 속 사용자가 올바른 사용자라고 판단하여 Access 토큰을 재발급한다.
- 즉, 입력된 인덱스(예를들어 2)에 해당하는 DB의 Access 토큰이 입력된 Access 토큰과 같은지 확인하고 같다면 Access 토큰 속 사용자가 맞다고 판단했다.
But,
- Access 토큰의 원본과 비교한다는 점에서 Access 토큰의 조작을 막을 수 있다해도, 다른 사람이 Access 토큰을 탈취하여 무작위 인덱스 번호를 쭉 갈겨버린다면 뚫리기 마련이다.
뭔가 처음엔 될 것 같아서 시도해보면 터무니없는 방법임을 알게 된다. 자괴감온다. ㅠㅠ
그래도 고민을 하면서 JWT에 대한 이해는 깊어졌다.
5. 결론
그래서 Refresh 토큰을 클라이언트로 보내도 되는가? 안된다면 어떻게 해결하는가?
개인적으로는 Refresh 토큰 그대로 클라이언트로 전송해도 괜찮을 것 같다. 그리고 그게 제일 무난한 방법인 것 같다.
클라이언트 측에서 HTTPOnly 옵션과 Secure 코딩을 적용한 쿠키에 토큰을 보관하는 방법이 여러 기술 블로그들의 주류였다.
그래도 더 개선해보면
Refresh Token Rotation 방법이 있다.
Access 토큰의 짧은 수명이 다하고 Refresh 토큰을 통해 재발급할때, Refresh 토큰도 바로 재발급 해버리는 것이다.
그렇게 되면 Refresh 토큰의 조작여부를 쉽게 파악할 수 있고 Refresh 토큰 자체도 클라이언트에서 조금 더 안전하다.
(계속 바뀌니까)
참고:
'Spring' 카테고리의 다른 글
[Spring] Spring Security에서 Firebase JWT사용하기 (0) | 2022.03.08 |
---|---|
[Spring] Spring Security에서 HttpSecurity와 WebSecurity의 차이 (0) | 2022.02.16 |
[Spring] JWT를 사용한 로그인 구현 1 (2) | 2021.08.02 |
[Spring] H2 데이터베이스 사용하기 (0) | 2021.08.01 |
[Spring] 카카오 아이디로 로그인하기 - 2 (0) | 2021.07.29 |