Refresh Token 문제 및 해결 과정
현재 상황
현재 시스템은 JWT(JSON Web Token)를 활용한 인증 방식을 사용한다.
사용자가 로그인을 통해 인증을 요청하면, 서버는 사용자의 인증을 처리하고 Access Token과 Refresh Token을 발급한다.
Access Token에는 사용자의 pk와 역할(role)이 포함되어 있으며, 이는 사용자의 웹 브라우저에 쿠키 형태로 저장된다. 이 Access Token을 활용해 사용자는 서버에 인증이 필요한 데이터 요청을 할 수 있다.
그러나 Access Token에는 사용자에 대한 정보가 포함되어 있기 때문에, 이 토큰이 탈취되면 사용자의 정보 유출의 위험이 있다.
이를 방지하기 위해 서버에서는 Access Token의 유효 시간을 30분으로 짧게 설정하였다. 이렇게 함으로써 보안상의 위험은 줄일 수 있었지만, 30분이라는 짧은 유효 시간 때문에 사용자는 잦은 인증 과정을 거쳐야 하는 문제가 발생한다.
이 문제를 해결하기 위해, 서버에서 유효 시간을 2주로 설정한 Refresh Token을 추가로 사용자에게 발급해준다. 이 Refresh Token은 서버의 Redis에 저장되어 관리된다. 사용자가 만료된 Access Token으로 서버에 요청을 하게 되면, 서버는 사용자의 Refresh Token을 확인하고 새로운 Access Token을 재발급하는 방식으로 동작한다.
RefreshToken
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "refresh", timeToLive = 1209600)
public class RefreshToken {
@Id
private String id;
private String role;
private String ip;
@Indexed
private String refreshToken;
}
- RefreshToken 클래스에서는 토큰의 id, role, ip, refreshToken을 속성으로 가지며, Redis에 저장될 때 'refresh'라는 키와 함께, 유효 시간(timeToLive)을 2주로 설정하여 저장됩니다.
토큰 재발급
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String requestUrl = request.getRequestURL().toString();
boolean containsApiAuth = requestUrl.contains("/api/auth/");
boolean containsAdmin = requestUrl.contains("/th/admin/");
if ((containsApiAuth || containsAdmin) && isCookieVerify(request, JwtVO.ACCESS_TOKEN)) {
String token = findCookieValue(request, JwtVO.ACCESS_TOKEN).replace(JwtVO.TOKEN_PREFIX, "");
log.debug("accessToken={}", token);
try {
PrincipalDetails loginMember = jwtProcess.verify(token);
Authentication authentication = new UsernamePasswordAuthenticationToken(
loginMember, null, loginMember.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
reIssueToken(request, response);
}
}
chain.doFilter(request, response);
}
private void reIssueToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (isCookieVerify(request, JwtVO.REFRESH_TOKEN)) {
String refreshToken = findCookieValue(request, JwtVO.REFRESH_TOKEN);
String jwtToken = refreshToken.replace(JwtVO.TOKEN_PREFIX, "");
// 리프레시 토큰 유효성 검사
try {
jwtProcess.isSatisfiedToken(jwtToken);
} catch (Exception e) {
handleTokenVerificationFailure(response);
return;
}
RefreshToken findRefreshToken
= findRefreshTokenOrElseThrowEx(refreshTokenRedisRepository, refreshToken);
// 토큰 재발급
String memberId = findRefreshToken.getId();
String memberRole = findRefreshToken.getRole();
String newAccessToken = jwtProcess.createNewAccessToken(Long.valueOf(memberId), memberRole);
log.debug("[토큰 재발급]accessToken={}", newAccessToken);
// 토큰을 쿠키에 추가
addCookie(response, JwtVO.ACCESS_TOKEN, newAccessToken);
}
}
private void handleTokenVerificationFailure(HttpServletResponse response) throws IOException {
addCookie(response, ISLOGIN, "false");
ObjectMapper objectMapper = new ObjectMapper();
ResponseDto<Object> responseDto = new ResponseDto<>(-1, "만료된 토큰입니다.", null);
String responseBody = objectMapper.writeValueAsString(responseDto);
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write(responseBody);
response.getWriter().flush();
response.getWriter().close();
}
- doFilterInternal 메서드에서는 요청 URL이 '/api/auth/' 또는 '/th/admin/'를 포함하고, 쿠키에 Access Token이 있는 경우 토큰을 검증하고, 인증된 사용자에 대한 Authentication 객체를 SecurityContext에 설정한다. 만약 토큰 검증에 실패한 경우에는 reIssueToken 메서드를 호출하여 새로운 토큰을 발급는다.
- reIssueToken 메서드에서는 쿠키에 Refresh Token이 있는 경우에 토큰을 검증하고, 검증에 실패한 경우에는 handleTokenVerificationFailure 메서드를 호출하여 실패 메시지를 응답한다. 토큰 검증에 성공한 경우에는 Redis에 저장된 Refresh Token을 조회하고, 조회된 Refresh Token의 id와 role을 이용하여 새로운 Access Token을 생성하고, 쿠키에 추가한다.
문제 상황
시스템에서는 특정 사용자 A가 로그인을 진행하고, 결과로 accessToken과 refreshToken을 쿠키로 발급 받는다.
이후 다른 사용자 B가 비정상적인 방법으로 사용자 A의 refreshToken을 탈취하고, 이를 자신의 쿠키에 추가한다. 추가적으로 B는 임의의 accessToken을 자신의 쿠키에 저장한다. 이 상태에서 사용자 B가 인증이 필요한 페이지에 접근을 시도하면, 서버는 B의 refreshToken을 확인하고, 새로운 accessToken을 발급해준다.
이로 인해 사용자 B는 사용자 A의 권한으로 인증이 필요한 페이지에 접근하는 보안상의 문제가 발생한다.
원인
- Refresh Token을 사용자의 쿠키에 직접 저장
- 사용자의 쿠키가 탈취될 경우, 함께 저장된 Refresh Token 또한 노출되는 위험이 있다.
- 쿠키는 클라이언트 사이드에서 관리되기 때문에 여러가지 방법으로 탈취될 수 있다.
- 예를 들어, 크로스 사이트 스크립팅(XSS) 공격을 통해 악성 스크립트가 쿠키 정보를 탈취하거나, 공용 네트워크를 통해 통신하는 동안 쿠키 정보가 노출될 수 있습니다. 따라서, Refresh Token과 같은 중요한 인증 정보를 쿠키에 저장하는 것은 보안 위험을 초래할 수 있다.
- 재발급 과정에서의 보안성이 부족
- 현재 시스템에서는 Refresh Token이 유효한지만 확인하고 새로운 Access Token을 발급하고 있다.
- 이 방식은 Refresh Token이 탈취된 경우, 악의적인 사용자가 이를 이용해 새로운 Access Token을 계속 발급받을 수 있게 한다. 즉, 한 번 Refresh Token이 노출되면 해당 사용자의 계정이 계속해서 위험에 노출되는 상황이 발생한다.
- 토큰 재발급 과정에서 기존 Refresh Token을 삭제하지 않고, 새로운 토큰을 재발급하지 않음
- 일반적으로 토큰 기반 인증 시스템에서는 새로운 Access Token을 발급받을 때마다 Refresh Token도 함께 갱신하여, 기존의 Refresh Token이 노출되더라도 그 영향을 최소화한다.
- 하지만 현재 시스템에서는 이런 방식을 채택하지 않고 있어서, 한 번 노출된 Refresh Token으로 계속해서 새로운 Access Token을 발급받을 수 있게 된다. 이는 사용자 A의 계정이 사용자 B에게 계속해서 노출될 수 있음을 의미한다.
해결 방법
이러한 문제를 해결하기 위해
1. Refresh Token의 PK를 UUID로 랜덤하게 생성하여 사용자에게 Refresh Token의 Id만을 제공한다.
2. Refresh Token의 필드로 사용자 IP 주소를 추가한다. 해당 IP 주소로 재발급 과정에서 사용자를 검증한다.
3. 재발급 과정에서 기존 Refresh Token을 삭제한 후 새로운 Access Token과 Refresh Token 모두를 발급해준다.
과정
- 사용자가 인증이 필요한 리소스를 요청한다.
- 서버에서는 쿠키로부터 사용자의 Access Token을 조회하고 유효성을 검사한다.
- 사용자의 Access Token가 만료되었다 가정한다.
- 서버에서는 쿠키로부터 사용자의 Refresh Token ID를 조회한다.
- Refresh Token ID를 사용해 Redis로부터 Refresh Token을 조회한다.
- 조회한 Refresh Token의 유효성을 검사한다.
- Refersh Token이 유효한 경우
- 재발급을 요청한 사용자의 IP 주소와 Refresh Token의 IP 주소를 비교한다.
- IP 주소가 동일하다면 기존 Refresh Token을 Redis로부터 삭제한다.
- 사용자에게 새로운 Access Token과 Refresh Token ID를 발급해준다.
- 재발급을 요청한 사용자의 IP 주소와 Refresh Token의 IP 주소를 비교한다.
- Refresh Token이 유효하지 않은 경우
- 사용자를 로그인 페이지로 리다이렉트 시킨다.
- Refersh Token이 유효한 경우
RefreshToken
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "refresh", timeToLive = 1209600)
public class RefreshToken {
@Id
private String id;
private String memberId;
private String role;
private String ip;
private String refreshToken;
}
- 사용자의 pk를 저장하는 memberId 필드를 추가하였다.
- 재발급 과정에서 사용하기 위한 ip 필드를 추가하였다.
토큰 재발급
private void reIssueToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!isCookieVerify(request, JwtVO.REFRESH_TOKEN)) {
return;
}
String refreshTokenId = findCookieValue(request, JwtVO.REFRESH_TOKEN);
RefreshToken findRefreshToken = findRefreshTokenOrElseThrowEx(refreshTokenRedisRepository, refreshTokenId);
validateToken(request, response, findRefreshToken);
}
private void validateToken(HttpServletRequest request, HttpServletResponse response, RefreshToken findRefreshToken)
throws IOException {
String jwtToken = findRefreshToken.getRefreshToken().replace(JwtVO.TOKEN_PREFIX, "");
String clientIpAddress = HttpServletUtil.getClientIpAddress(request);
try {
jwtProcess.isSatisfiedToken(jwtToken);
validateIpAddress(findRefreshToken, clientIpAddress);
} catch (Exception e) {
handleTokenVerificationFailure(response);
}
reIssueNewToken(response, findRefreshToken, clientIpAddress);
}
private void validateIpAddress(RefreshToken findRefreshToken, String clientIpAddress) {
if (!findRefreshToken.getIp().equals(clientIpAddress)) {
throw new UnauthorizedAccessException("다른 IP에서의 접근이 감지되었습니다. 보안을 위해 접속이 종료됩니다.");
}
}
private void reIssueNewToken(HttpServletResponse response, RefreshToken findRefreshToken, String clientIpAddress) {
String memberId = findRefreshToken.getMemberId();
String memberRole = findRefreshToken.getRole();
String newAccessToken = jwtProcess.createNewAccessToken(Long.valueOf(memberId), memberRole);
refreshTokenRedisRepository.delete(findRefreshToken);
String newRefreshTokenId = saveRefreshToken(jwtProcess, refreshTokenRedisRepository,
memberId, memberRole, clientIpAddress);
addCookie(response, JwtVO.ACCESS_TOKEN, newAccessToken);
addCookie(response, JwtVO.REFRESH_TOKEN, newRefreshTokenId);
}
private void handleTokenVerificationFailure(HttpServletResponse response) throws IOException {
addCookie(response, ISLOGIN, "false");
ObjectMapper objectMapper = new ObjectMapper();
ResponseDto<Object> responseDto = new ResponseDto<>(-1, "만료된 토큰입니다.", null);
String responseBody = objectMapper.writeValueAsString(responseDto);
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write(responseBody);
response.getWriter().flush();
response.getWriter().close();
}
- 사용자로의 쿠키에서 Refresh Token의 ID를 추출해 Redis에서 조회한다.
- Refresh Token에 저장된 IP 주소와 재발급을 요청한 사용자의 IP 주소를 비교한다.
- 기존의 Refresh Token을 삭제한다.
- 새로운 Access Token과 Refresh Token을 발급해준다.
Refresh Token 생성
public class TokenUtil {
public static String saveRefreshToken(JwtProcess jwtProcess,
RefreshTokenRedisRepository refreshTokenRedisRepository,
String memberId,
String memberRole,
String ip) {
String refreshTokenId = UUID.randomUUID().toString();
String refreshToken = jwtProcess.createRefreshToken(refreshTokenId, memberId, memberRole, ip);
refreshTokenRedisRepository.save(RefreshToken.builder()
.id(refreshTokenId)
.memberId(memberId)
.role(memberRole)
.ip(ip)
.refreshToken(refreshToken)
.build());
return refreshTokenId;
}
}
- Refresh Token의 pk를 UUID로 랜덤하게 생성한다.
'기술 블로그 > ToValley' 카테고리의 다른 글
[Spring Boot, WebSocket, Kafka, MongoDB] 채팅 기능 구현#2. 도메인 설계 (0) | 2024.03.21 |
---|---|
[Spring Boot, WebSocket, Kafka, MongoDB] 채팅 기능 구현#1. 기술 선정 (0) | 2024.03.20 |
[Spring Boot + React]배포#5. AWS 보안 그룹 설정 (0) | 2023.12.17 |
[Spring Boot + React]배포#4. AWS RDS 생성 및 설정 (1) | 2023.12.17 |
[EC2 + RDS + Docker + Spring Boot + React]배포#11. 문제 해결 과정 (0) | 2023.12.16 |