2023. 9. 8. 13:57ㆍDevelopment
안녕하세요!
이번 시간에는 Refresh Token이라는 새로운 개념에 대해 설명 드리려고 왔어요.
보안이라는 것이 참 어려운 분야 같아요.
어떤 보안 대책을 세워도 무조건 뚫리는 방법이 존재한다는 것이 어떻게 보면 막막하기도 한데 그만큼 흥미롭기도 하죠.
이 Refresh Token이라는 개념도 Access Token의 취약점을 보완하기 위해서 고안되었어요.
그렇게 어렵지 않은 구현 난이도로 나름 높은 보안성을 보여서 저도 도입해보려고 해요.
이 게시물은 Refresh Token에 대해 다루고 있고, 설계와 Access Token의 후행 단계예요.
설계에 관한 글을 보고 오시지 않은 분들은 이 게시물을 먼저 보는 것을 추천 드릴게요.
JWT 기반 인증 2편 - 스프링 시큐리티 JWT 설계
안녕하세요! 이번 시간에는 스프링 시큐리티에서 JWT의 인증 흐름을 다뤄보려고 해요. 스프링 시큐리티에서 JWT를 구현하기 위한 설계 단계라고 봐주셨으면 좋겠어요. 소프트웨어 공학에서는 설
dalmeng-commeng.tistory.com
Access Token의 구현에 관한 글을 보고 오시지 않은 분들은 이 게시물을 먼저 보는 것을 추천드릴게요.
JWT 기반 인증 3편 - 스프링 시큐리티 JWT 인증 · 인가 구현 (1)
안녕하세요! 지금부터 본격적인 구현에 들어가 보려고 해요. 저번 시간에 정리한 설계 순서대로 구현을 할 예정이니, 이전 게시물을 보고 오시는 것을 추천드려요. https://dalmeng-commeng.tistory.com/4 J
dalmeng-commeng.tistory.com
이 프로젝트에서 작성한 모든 코드는 달맹의 깃허브에 있어요!
GitHub - dalmengs/tokenBasedAuthentication: Token Based Authentication in Spring Security
Token Based Authentication in Spring Security. Contribute to dalmengs/tokenBasedAuthentication development by creating an account on GitHub.
github.com
이번 시간에는 Refresh Token에 관한 개념과 JWT 필터까지만 구현해볼게요.
1. JWT란? - What is JWT?
2. 스프링 시큐리티 JWT 설계
3. 스프링 시큐리티에서 JWT 인증 · 인가 구현
4. Refresh Token 도입
4 - 1. Refresh Token 개요 - Overview
4 - 2. 이전과 달라지는 점 - What is the Improvement Point Compare to Before?
4 - 3. 설정 파일 수정 - Properties File
4 - 4. JWT Provider 구현 - JWT Provider
4 - 5. JWT Filter 구현 - JWT Filter
4 - 6. 서비스 계층 구현
4 - 7. 컨트롤러 계층 구현
4 - 8. 테스트
5. 리팩토링
4 - 1 . Refresh Token 개요 - Overview
이전 시간에도 말했지만, Access Token이 탈취되면 굉장히 위험해져요.
그 이유는 서버는 누가 요청했는지 신경쓰지 않고, 그저 토큰의 내용만 보고 판단하기 때문에 누구나 탈취한 토큰을 가지고 인증할 수 있기 떄문이에요.

그러면 Access Token의 유효 시간을 확 줄여버리면 어떨까요?
만약 나쁜 사람이 Access Token을 탈취해도 이미 만료가 되어버려 쓸 수 없게 만드는 거죠.
좋은 방법이지만 큰 단점이 있어요.
사용자를 지속적으로 로그인을 시켜야 한다는 것이에요.
한 시간 단위로 사용자가 로그인을 해야한다면 굉장히 짜증나겠죠?
만약 게시판에 글을 쓰고있다고 가정해봅시다.
글쓰기 창을 열 때는 인증이 되어있었는데, 열심히 공을 들여 글을 완성하고, 글 올리기 버튼을 눌렀더니 인증이 만료가 되어 쓴 내용이 전부 날아가버렸어요.
사용자 입장에서는 말도 안 되게 화가 나겠죠?
그래서 유효 시간을 대폭 줄이는 것은 마냥 좋은 방법은 아닐 거예요.
그래서 도입된 개념이 Refresh Token이에요.
우선 Access Token의 지속 시간을 대폭 줄이는 것은 똑같아요.
하지만 클라이언트에게 Refresh Token을 같이 발급해줘요.
그리고 Refresh Token은 Access Token에 비해 지속 시간이 매우 길어요.

로그인에 성공한다면 클라이언트는 Access Token과 Refresh Token을 같이 발급받고, 다음에 요청할 때 이 두 토큰과 함께 요청을 보내요.
만약 Access Token이 만료되었다면 Refresh Token을 보고 이전에 인증한 사용자라는 것을 인지해요.
그 후에는 Access Token을 다시 발급해주는 거죠.

클라이언트와 서버 사이에 토큰을 주고 받는 모습을 볼게요.

4 - 2. 이전과 달라지는 점 - What is the Improvement Point Compare to Before?
1. 설정 파일에 Refresh Token Secret Key와 Refresh Token Valid Time 정의하기
Refresh Token과 Access Token이 굳이 같은 Secret Key를 쓸 필요가 없겠죠?
다른 Secret Key를 정의해서 보안성을 높일게요.
2. JWT Provider 구현
일반 토큰을 만드는 것처럼 Refresh Token을 만드는 로직을 추가하면 돼요.
3. JWT Filter 구현
아마도 이 부분이 조금 복잡해질거예요.
Access Token과 Refresh Token의 만료 여부에 따라 분기가 많아지거든요.
하지만 제가 흐름도(Flow Chart)를 다 준비해 놓아서 이해하기 쉬울 거예요.
4. 서비스 계층과 컨트롤러 계층 구현
이 과정은 아주 간단해요.
Refresh Token을 발급하는 기능만 추가하면 되거든요.
이 정도만 추가하면 Refresh Token을 도입할 수 있어요.
차례대로 구현해볼게요.
4 - 3. 설정 파일 수정 - Properties File
src/main/resources/application.properties에 Refresh Token에 대한 내용을 추가해줄 거예요.
Access Token과 형식은 똑같기 때문에 설명은 하지 않을게요.
jwt.access.token.key=C2EB888AD...
jwt.refresh.token.key=RGFsbWVuZ0...
jwt.access.token.validTime=3600000
jwt.refresh.token.validTime=604800000
Access Token과 Refresh Token은 유효 시간도 다르고, Secret Key도 달라서 모두 따로 정의해주었어요.
4 - 4. JWT Provider 구현 - JWT Provider
우선 Refresh Token은 다른 Secret Key를 사용하기 때문에 토큰을 만들 때 사용되는 Key도 다르겠죠?
그래서 Access Token의 Key를 초기화해주는 과정에서 Refresh Token의 Key도 같이 초기화해줄게요.
@PostConstruct
public void init(){
this.accessTokenKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessTokenSecretKey));
this.refreshTokenKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshTokenSecretKey));
}
우리가 추가적으로 정의해야 할 메소드를 볼게요.
- Refresh Token 생성
- 사용자 이름 가져오기
- 요청 객체에서 토큰 가져오기
- 토큰 만료 여부 확인하기
Access Token을 만들 때와 크게 다르지 않죠?
내용이 중복되어 있어서 쉽게 구현할 수 있어요.
1. Refresh Token 생성
public String createRefreshToken(String username){
Date now = new Date();
Claims claims = Jwts.claims()
.setSubject("refresh_token")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshTokenValidTime));
claims.put("username", username);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.signWith(SignatureAlgorithm.HS256, refreshTokenSecretKey)
.compact();
}
달라진 점이 거의 없어요.
다른 Key와 만료 시간을 사용한 점과 토큰의 이름이 refresh_token으로 바뀐 점밖에 없어요.
2. 사용자 이름 가져오기
이 메소드는 기존의 메소드를 활용했어요.
public String getUsername(String token, boolean isAccessToken) {
String secretKey = accessTokenSecretKey;
if(!isAccessToken) secretKey = refreshTokenSecretKey;
return (String) Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.get("username");
}
인자(Parameter)인 isAccessToken이 true면 Access Token의 Key를 사용하고, false면 Refresh Token의 Key를 사용하는 방식으로 변경해주었어요.
3. 요청 객체에서 토큰 가져오기
요청 객체의 헤더에서 토큰을 가져오는데, 토큰의 이름이 refresh_token으로 변경됐죠?
그 부분만 반영해주면 돼요.
public String resolveRefreshToken(HttpServletRequest request) {
return request.getHeader("refresh_token");
}
4. 토큰 만료 여부 확인하기
이 메소드도 기존의 메소드를 활용했어요.
똑같이 isAccessToken을 추가적인 인자로 사용해서 Access Token과 Refresh Token을 구분해 주었어요.
구분 로직은 위에서 언급했던 로직과 완전히 똑같아요.
public boolean validateToken(String token, boolean isAccessToken) {
try {
String secretKey = accessTokenSecretKey;
if(!isAccessToken) secretKey = refreshTokenSecretKey;
Claims claims = Jwts
.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
4 - 5. JWT Filter 구현 - JWT Filter
이 부분은 토큰의 개수가 늘어나면서 조건 분기가 많아졌어요.
우리는 무엇을 고려해야 할까요?
우선 기본 로직과 똑같이 Access Token이 없으면 바로 다음 필터로 넘어가면 돼요.
Access Token이 있다면 검증을 해줘야겠죠?
Access Token이 만료되지 않았다면 Access Token만으로 인증을 할 수 있다는 의미예요.
따라서 기존과 똑같이 인증 객체를 만들어 Security Context Holder에 넣어주면 돼요.
만약 Access Token이 만료되었다면 이 때 Refresh Token을 확인해줘요.
Refresh Token이 만료되었다면 Access Token과 Refresh Token이 전부 만료된 상태죠?
이 때는 인증할 수 없다고 판단하고 다음 필터로 그냥 넘어가면 돼요.
만약 Refresh Token이 만료되지 않았다면 검증을 거쳐 Access Token과 Refresh Token을 다시 생성해요.
다음으로 새로운 토큰을 이용해서 기존과 똑같이 인증 객체를 만들어 Security Context Holder에 넣어주면 돼요.
말로 설명하니깐 더 어려워보여요.
제가 작성한 흐름도를 보면서 로직을 이해해보아요.

차근차근 읽어본다면 이해가 될 거예요.
아래는 수정된 JWT Filter 코드예요.
위 로직을 따라 그대로 작성했어요.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtUtil.resolveToken((HttpServletRequest)request);
String refreshToken = jwtUtil.resolveRefreshToken((HttpServletRequest) request);
log.info("JWT Filter Start");
if(token != null){
if(!jwtUtil.validateToken(token, true)){
log.info("Invalid Access Token");
if(refreshToken != null && jwtUtil.validateToken(refreshToken, false)){
Optional<User> user = userRepository.findByUsername(jwtUtil.getUsername(refreshToken, false));
if(user.isPresent()){
String newAccessToken = jwtUtil.createToken(user.get().getUsername(), user.get().getRole());
String newRefreshToken = jwtUtil.createRefreshToken(user.get().getUsername());
log.info("Valid Refresh Token : Create New Tokens - Username : " + user.get().getUsername());
((HttpServletResponse)response).setHeader("access_token", newAccessToken);
((HttpServletResponse)response).setHeader("refresh_token", newRefreshToken);
Authentication authentication = jwtUtil.getAuthentication(newAccessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
else{
log.info("Valid Access Token - Username : " + jwtUtil.getUsername(token, true));
Authentication authentication = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
이상으로 Refresh Token에 대한 핵심적인 구현이 끝났어요.
컨트롤러 계층과 서비스 계층의 구현은 너무나 간단해서 다음 게시물에 테스트와 함께 올리도록 할게요.
제 글이 많은 도움이 되었길 바라며,
긴 글 봐주셔서 감사합니다!
멋진 개발자가 되기 위해 더 열심히 달리겠습니다!
- 달맹 -
'Development' 카테고리의 다른 글
| JWT 기반 인증 8편 [끝] - 리팩토링 (0) | 2023.09.09 |
|---|---|
| JWT 기반 인증 7편 - Refresh Token 도입 (2) (0) | 2023.09.08 |
| JWT 기반 인증 5편 - 스프링 시큐리티 JWT 인증 · 인가 구현 (3) (0) | 2023.09.08 |
| JWT 기반 인증 4편 - 스프링 시큐리티 JWT 인증 · 인가 구현 (2) (1) | 2023.09.07 |
| JWT 기반 인증 3편 - 스프링 시큐리티 JWT 인증 · 인가 구현 (1) (0) | 2023.09.05 |