Refresh Token 구현하기 2
이전 이야기
저번글에 Refresh Token을 구현할 때 클라이언트 쪽에서 SetInterval을 통해서 30분마다 AccessToken을 요청하는 로직을 만들어서 RefreshToken을 구현을 했었는데 이 방법은 보안상 으로도 취약하고 클라이언트 쪽에 별도의 로직을 만들어야 하는 문제점이 있는 방식이다.
이번에는 이 문제를 보완하기 위한 서버에서 AccessToken의 유효기간을 체크하고 RefreshToken을 통해서 AccessToken을 발급하는 과정을 거쳐서 Refresh Token을 구현해 보도록 하겠다.
고민 점 🤔
※ 서버에서 유효성을 검사하면 AccessToken을 사용하는 모든 Service로직에서 엄청난 중복 코드가 발생하게 된다.
AccessToken의 유효성을 검사하는 로직을 서비스 레이어에 구현할 필요 없이 할 수 있는 방법은 없을까?
내가 생각 해낸 방법은 interceptor에서 처리를 하는 것이다. 만약 서비스 레이어에서 각각의 메소드마다 AccessToken의 유효성을 체크하는 로직을 구현하면 유지보수 측면에서 너무나 큰 악재가 된다. 그렇기 떄문에 AccessToken의 유효기간 체크를 interceptor한 곳에서 하면 중복된 로직이 없어 지기 떄문에 interceptor에서 처리 하도록 결정했다.
AuthenticationInterceptor.class
인터셉터를 구현하면서 AccessToken을 decode해서 userId값을 얻은 다음 userId값으로 RefreshToken을 Repository에서 찾을려고 했지만 그렇게 되면 인터셉터가 Repository에 접근을 하게 되는데 인터셉터는 View와 Controller사이에 있는 친구이기 떄문에 Presentaion Layer라고 할 수 있을 것 같다 Presentation Layer에서 Repository에 접근하는 건 허용되지 않는다.
도저히 다른 방법이 떠오르지 않아 결국 모든 AccessToken이 필요한 요청에 대해서 RefreshToken까지 함께 전송하는 것으로 결정을 했다. (프론트엔드 쪽을 전부 다 고쳐야 함..)
public class AuthenticationInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
public AuthenticationInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
if (authorization == null) {
return true;
}
if (authorization.length() == 6) {
request.setAttribute("userId", null);
return true;
}
if (!authorization.startsWith("Bearer ")) {
throw new AuthenticationError();
}
String accessToken = authorization.substring("Bearer ".length());
try {
if (accessToken.isBlank()) {
request.setAttribute("userId", null);
return true;
}
// AccessToken의 유효성 검사 로직
if (!jwtUtil.isValidAccessToken(accessToken)) {
throw new AccessTokenExpirationException();
}
Long userId = jwtUtil.decode(accessToken);
request.setAttribute("userId", userId);
return true;
} catch (AccessTokenExpirationException exception) {
Long userId = jwtUtil.decode(accessToken);
// RefreshToken 값 가져오기
String refreshTokenValue = request.getHeader("RefreshToken");
// RefreshToken이 null이 아니고 유효성 검사를 통과 했을 경우 //
if (refreshTokenValue != null && jwtUtil.isValidRefreshToken(refreshTokenValue)) {
// 새로운 AccessToken생성
String newAccessToken = jwtUtil.encode(userId);
// 새로운 AccessToken을 응답으로 돌려줌
response.setHeader("Authorization", "Bearer " + newAccessToken);
// 새로운 AccessToken으로 계속해서 로직 진행
request.setAttribute("userId", userId);
return true;
} else {
throw new InValidRefreshTokenException();
}
}
}
}
아래는 토큰의 유효성 검사 로직이다. RefreshToken이나 AccessToken이나 똑같은 JWT이기 떄문에 사실 유효성 검사하는 로직은 똑같다고 할 수 있다. 유효기간이 아직 남아 있으면 true를 반환하고 만료됬다면 false를 반환한다.
public boolean isValidAccessToken(String accessToken) {
try {
// secret은 토큰을 만들 때 썻던 서명이다.
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(accessToken).getBody();
Date expiration = claims.getExpiration();
Date now = new Date();
return expiration.after(now);
} catch (JwtException e) {
return false;
}
}
프론트엔드
const refreshToken = apiService.getRefreshToken();
try {
const response = await userApiService.updateAddress(deliveryInformation, accessToken, refreshToken);
if (response !== null) {
const newAccessToken = response.headers.authorization.split(' ')[1];
localStorage.setItem('accessToken', newAccessToken);
}
} catch (e) {
const error = e.response.data;
this.errorMessage = error.message;
// 리프레쉬 토큰의 유통기한이 만료됬을 경우
if (this.errorMessage === 'InValid RefreshToken!') {
const navigate = useNavigate();
// 로그아웃 처리
localStorage.setItem('accessToken', '');
localStorage.setItem('refreshToken', '');
apiService.setAccessToken('');
apiService.setRefreshToken('');
// 로그인 페이지로 이동 시키기
if (confirm('로그인 기간이 만료되었습니다 \n 다시 로그인 하겠습니까?')) {
navigate('/login');
} else {
navigate('/');
}
this.publish();
}
}
이렇게 RefreshToken을 구현을 했지만 아쉬운점이 많다. 예외 처리 부분을 사실상 AccessToken을 사용하는 모든 곳에서 또 중복이 발생하기 때문이다.