Refresh Token을 왜 구현해야 하는가?
우리는 일반적으로 한번 로그인한 사이트에서는 어느 정도 계속 로그인이 유지되길 바란다. 특히 자주 방문하는 웹사이트를 매번 로그인해야 한다면 정말 번거로운 일이 된다. 하지만 이를 위해서 Access Token의 유효기간을 매우 길게 설정한다면, 악의적인 공격자가 이 Access Token을 탈취해서 사용할 수 있는 위험성이 커진다.
이를 해결하기 위해서 Access Token 자체의 유효기간은 짧게 설정(예: 한 시간)하되, Access Token이 expired될 경우 로그인 없이 새롭게 발급받을 수 있게 도와주는 토큰이 Refresh Token이다. Refresh Token은 더 긴 유효기간을 가지고 있으며, (예: 일주일) 이 유효기간 동안 Expired된 Access Token을 새롭게 발급받을 수 있도록 도와준다.
정리하자면 다음과 같다.
- 사용자 인증 정보를 가진 Access Token은 짧은 유효기간을 가진다.
- Access Token을 새로 발급받을 수 있게 도와주는 Refresh Token은 비교적 긴 유효기간을 가진다.
- 만약 둘 다 expired 되었다면, 사용자에게 다시 로그인하도록 유도한다.
Refresh Token 동작 과정
그럼 Refresh Token은 서버-클라이언트 구조에서 구체적으로 어떻게 동작할까? 내 프로젝트에서는 아래와 같이 구성했다.
먼저, Access Token을 헤더에 넣고 필요한 API를 호출한다. 이때 Access Token이 만료되면 다음과 같은 401
에러 코드가 반환된다.
{
"code": "AUTH001",
"success": false,
"message": "만료된 엑세스 토큰입니다.",
"timestamp": "2024-07-09T18:01:53.8762857",
"status": 401
}
참고로, 이렇게 JWT 토큰에서 난 에러를 처리하는 방식은 다음 글을 읽어보면 된다.
클라이언트는 위와 같이 에러 코드가 반환된 경우, /auth/reIssuance API를 호출해서 Refresh Token을 기반으로 새로운 Access Token을 발급받아야 한다.
========== 요청 ==========
헤더: 원래는 Authorization에 Access Token을 넣어줬다면,
이제는 Access Token이 만료되었으므로 삭제하고 Refresh Token을 넣어준다.
*내가 구현한 로직에서는 Authorization에 토큰이 없으면 무조건 에러가 나기 때문에 넣어주는 것
바디: Refresh Token을 넣어준다.
{
"jwtRefreshToken": "Bearer eyJ0-..."
}
정상적으로 리프레시 토큰이 검증되어서 새로운 Access Token이 발급되는 경우에는 다음과 같이 body로 새로운 Access Token과 Refresh Token이 반환된다. 이를 다시 쿠키에 넣어서 사용하면 된다.
========== 응답 ==========
{
"jwtAccessToken": "Bearer eyJ0...",
"jwtRefreshToken": "Bearer eyJ0..."
}
만약 Refresh Token마저 유효 기간이 지나서 더 이상 사용할 수 없거나, 잘못된 Refresh Token을 사용하려고 하는 경우에는 다음과 같이 401
에러가 반환된다. 이 경우 쿠키에서 Access Token및 Refresh Token을 모두 삭제하고 로그인 페이지로 이동하면 된다.
========== 응답 ==========
{
"code": "AUTH001",
"success": false,
"message": "만료된 엑세스 토큰입니다.",
"timestamp": "2024-07-09T18:01:53.8762857",
"status": 401
}
//또는
{
"timestamp": "2024-07-09T18:11:00.5406045",
"success": false,
"code": "AUTH002",
"status": 401,
"message": "리프레시 토큰이 유효하지 않습니다."
}
로컬에 Redis 설치
이제 위의 과정을 코드로 구현해보자. 우리는 이 리프레시 토큰을 저장할 곳으로 Redis를 사용하려고 한다. 프로덕션에서는 Docker로 Redis를 사용하겠지만, local의 개발 환경에서는 직접 Redis를 설치해서 테스트해보려고 한다. 아래 글을 참고해서 설치 및 테스트를 해보면 된다.
build.gradle
그 후, Redis를 사용하기 위해서 필요한 종속성을 (자바 17, Springboot 3.2.3 기준으로) 다음과 같이 넣어준다.
dependencies {
//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
application.yml
application.yml에는 다음과 같이 넣어준다. (*local에서 개발하는 환경일 때는 host가 localhost여야 한다. 아래의 redis-server는 production에서 docker로 배포되는 경우의 세팅이다.)
spring:
data:
redis:
host: redis-server
port: 6379
RedisConfig
이제 Redis와의 연결을 설정해주도록 하자. RedisStandaloneConfiguration
를 통해서 host와 port를, LettuceClientConfiguration
를 통해서 세부적인 Timeout등을 설정해줄 수 있다.
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@Slf4j
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/* Use Lettuce for Redis(Jedis has been deprecated since Springboot 2.0) */
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(host, port);
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(1)) //No longer than 1 second
.shutdownTimeout(Duration.ZERO) //Immediately shutdown after application shutdown
.build();
log.info("Connected to Redis at {}:{}", host, port);
return new LettuceConnectionFactory(redisConfig, clientConfig);
}
}
RefreshToken
이제 Redis에 저장해줄 엔티티, 즉 Refresh Token을 만들어주자. 다른 엔티티를 만들 때처럼 비슷하게 해주면 되지만, 세 가지 다른 점은 @RedisHash
, @Indexed
, @TimeToLive
이다. @RedisHash
어노테이션은 해당 클래스의 인스턴스가 Redis의 해시(Hash) 데이터 구조로 저장된다는 것을 명시해준다. @Indexed
어노테이션은 해당 필드가 Redis에서 인덱스화되게 만들어 쿼리에서 빠르게 데이터를 조회할 수 있게 도와준다. @TimeToLive
어노테이션은 Redis에서 객체의 생명 주기를 관리하는 데 사용된다. @RedisHash
의 옵션으로 사용되는 timeToLive
는 모든 객체에 적용되지만, 이렇게 필드에 @TimeToLive
어노테이션을 사용하면 객체 별로 관리해줄 수 있다. 여기서는 우선 둘 다 14일로 지정해주었다.
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
@Getter
@NoArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 60*60*24*14)
public class RefreshToken { /* Redis에 저장해서 RefreshToken이 유효한지 검증 */
@Id
@Indexed
private String jwtRefreshToken;
// 맴버 이메일로 설정
private String authKey;
//리프레시 토큰의 생명 주기(14일)
@TimeToLive
private Long ttl;
@Builder
public RefreshToken(String jwtRefreshToken, String authKey) {
this.jwtRefreshToken = jwtRefreshToken;
this.authKey = authKey;
this.ttl = 1000L * 60 * 60 * 24 * 14;
}
}
RefreshTokenRepository
Redis를 Springboot에서 사용할 수 있는 방법이 두 가지가 있는데, 하나는 RedisTemplete
고 다른 하나는 CrudRepository
를 확장시키는 것이다. RedisTemplete은 좀 더 섬세한 동작을 가능하게 하지만, CrudRepository를 쓰는 편이 더 간편하기도 하고, 여기서는 Refresh Token 저장/삭제 정도의 로직만 사용할 것이기 때문에 CrudRepository를 쓰기로 한다. 아래와 같이 기존에 우리가 아는 CrudRepository 사용 방법으로 Redis 캐시를 조회하고, 저장하고, 삭제할 수 있다.
import Ness.Backend.domain.auth.inmemory.entity.RefreshToken;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {
Optional<RefreshToken> findRefreshTokenByJwtRefreshToken(String refreshToken);
}
RefreshTokenService
이제 위의 RefreshTokenRepository를 사용해서 Refresh Token을 저장하거나 삭제해준다. saveRefreshToken은 토큰을 저장해주는 로직이고, removeRefreshToken 토큰을 삭제해주는 로직이다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public void saveRefreshToken(String refreshToken, String authKey) {
RefreshToken token = RefreshToken.builder()
.jwtRefreshToken(refreshToken)
.authKey(authKey)
.build();
refreshTokenRepository.save(token);
}
@Transactional
public void removeRefreshToken(String refreshToken) {
refreshTokenRepository.findRefreshTokenByJwtRefreshToken(refreshToken)
.ifPresent(token -> refreshTokenRepository.delete(token));
}
}
AuthService
위에서 만든 로직들은 다음과 같은 상황에서 사용한다:
- 사용자가 회원가입/로그인을 진행한 경우: 새로운 Access Token 및 Refresh Token을 발급하고, Refresh Token은 Redis에 저장한다.
- (reIssuance) 사용자가 Access Token이 만료되어서 Refresh Token을 전달해주면서 Access Token 재발급을 원하는 경우: 우선 Refresh Token이 적절한지, Redis에 있는지 확인하고, 모두 적절할 경우 새롭게 Access Token을 발급해준다.
- (logout) 사용자가 로그아웃을 원하는 경우: Redis에 있는 Refresh Token을 삭제해준다.
회원가입/로그인 과정은 각자 서비스마다 다를테니 넘기고, 아래 코드는 reIssuance와 logout 상황이 구현되어 있다.
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final RefreshTokenService refreshTokenService;
@Transactional
public void logout(Member member, PostRefreshTokenDto postRefreshTokenDto) {
/* refreshToken 만료 여부 확인 */
if(refreshTokenRepository.findRefreshTokenByJwtRefreshToken(postRefreshTokenDto.getJwtRefreshToken()).isEmpty()){
throw new UnauthorizedException(ErrorCode.INVALID_REFRESH_TOKEN);
}
refreshTokenService.removeRefreshToken(postRefreshTokenDto.getJwtRefreshToken());
SecurityContextHolder.clearContext();
}
@Transactional
public GetJwtTokenDto reIssuance(Member member, PostRefreshTokenDto postRefreshTokenDto) {
/* refreshToken 유효성 확인 */
String refreshToken = postRefreshTokenDto.getJwtRefreshToken().substring(7);
if (!jwtTokenProvider.validRefreshToken(refreshToken)) {
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN);
}
/* refreshToken 만료 여부 확인 */
if(refreshTokenRepository.findRefreshTokenByJwtRefreshToken(postRefreshTokenDto.getJwtRefreshToken()).isEmpty()){
throw new UnauthorizedException(ErrorCode.INVALID_REFRESH_TOKEN);
}
final GetJwtTokenDto generateToken = GetJwtTokenDto.builder()
.jwtAccessToken("Bearer " + jwtTokenProvider.generateAccessToken(member.getEmail(), new Date()))
.jwtRefreshToken(postRefreshTokenDto.getJwtRefreshToken())
.build();
return generateToken;
}
}
AuthController
이제 위에서 구현한 모든 로직을 클라이언트에게 API 형태로 전달해주면 다음과 같다.
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/auth")
public class AuthController {
private final AuthService authService;
@PostMapping("/logout")
@Operation(summary = "로그아웃 요청", description = "로그아웃 요청 API 입니다.")
public ResponseEntity<?> logout(@AuthUser Member member, @RequestBody PostRefreshTokenDto postRefreshTokenDto) {
authService.logout(member, postRefreshTokenDto);
return new ResponseEntity<>(HttpStatusCode.valueOf(200));
}
@PostMapping("/reIssuance")
@Operation(summary = "JWT access 토큰 재발급 요청", description = "JWT access 토큰 재발급 요청 API 입니다.")
public GetJwtTokenDto reIssuance(@AuthUser Member member, @RequestBody PostRefreshTokenDto postRefreshTokenDto) {
return authService.reIssuance(member, postRefreshTokenDto);
}
}
OAuth로 회원가입/로그인과 결합된 더 자세한 코드를 보고 싶다면, 아래 경로에서 확인할 수 있다.
Docker끼리 연결
로컬에서 작업하고 있다면 그냥 application.yml
에서 설정한 host가 localhost면 되지만, 내 프로젝트에서는 지금 Springboot와 Redis를 모두 Docker로 띄워놓은 상황이다. 참고로, 아래 Dockerfile을 통해서 Redis를 설치했다.
FROM redis:7.0.9
EXPOSE 6379
RUN mkdir /var/run/redis;
COPY ./6379.conf /etc/redis/6379.conf
CMD ["redis-server", "/etc/redis/6379.conf"]
이때 단순히 Springboot Docker 안에서 localhost로 Redis를 지정하면 감지가 안된다. Docker Compose 등을 사용하거나 아니면 Docker의 Network를 설정해줘야 한다. 나는 더 간편한 방법인 Network 설정으로 이 문제를 해결했다. Docker가 설치되어 있는 배포 서버에 아래 명령어를 사용하면 두 Docker가 같은 네트워크 내에서 통신 가능하게 만들 수 있다.
...Redis와 Springboot Docker 이미지 pull 등 과정 생략...
docker network create [네트워크 이름]
docker network connect [네트워크 이름] redis-server
docker network connect [네트워크 이름] backend-server