-
Spring Boot, Redis를 이용하여 RefreshToken 발급하기취준/Project 2024. 5. 31. 10:34
나는 AccessToken은 Cookie로, RefreshToken은 Redis에 저장하여 AccessToken이 만료되더라도
사용자가 로그아웃 하지 않으면 RefreshToken을 이용해 새로운 Token을 발급하여 세션을 연장하는 코드를 완성하여 정리하고자 한당
절차
다음과 같은 단계로 코드를 정리해보려고 한다.
- Redis 연동
- Login시 AccessToken, RefreshToken 발급
- AccessToken 만료될 경우 RefreshToken을 이용하여 AccessToken 재발급
Redis 연동
맥북을 사용하고 있기 때문에 brew를 이용하여 redis를 설치하였다.
다운로드
터미널을 켜고 아래의 명령어를 차례대로 입력한다
- brew install redis (redis 설치)
- brew services start redis (redis 백그라운드 실행)
- redis-server (redis server 실행)
이와 같은 창이 뜨면 성공 properties 파일 작성
spring.redis.host=localhost spring.redis.port=6379
redis의 host와 port를 properties 파일에 작성하여 준다.
config 설정
@Configuration public class RedisConfig { @Value("${spring.redis.host}") private String host; @Value("${spring.redis.port}") private int port; @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(host, port); } }
- properties파일에서 작성한 host, port를 @Value로 받아온다.
- Redis의 Java 클라이언트는 Jedis와 Lettuce가 있는 데 사용법은 어렵지만 성능이 좋아 보편적으로 Lettuce를 사용한다고 하여 나도 Lettuce를 사용하여 구현하였다.
Redis에 저장할 Entity 선언
@RedisHash(value = "token", timeToLive = 604800) // 7일 @NoArgsConstructor @Getter @ToString public class RefreshToken{ @Id private Long id; @Indexed private String accessToken; private String refreshToken; public RefreshToken(Long id, String accessToken, String refreshToken){ this.id = id; this.accessToken = accessToken; this.refreshToken = refreshToken; } }
- RefreshToken을 Redis에 저장할 것이기 때문에 RefreshToken을 담고 있는 Entity를 선언한다.
- @RedisHash
- Redis에 저장할 자료구조임을 알려주는다. value는 해당 자료구조의 Key namespace가 된다.
- @Id
- @Id 어노테이션이 붙은 필드가 Redis Key 값이 되며 null로 세팅하면 랜덤값이 설정된다. keyspace와 합쳐져서 저장된 최종 키 값은 keyspace:id 가 된다.
- import org.springframework.data.annotation.Id를 import 해야 한다.
- @Indexed
- 해당 어노테이션을 이용해 @Id가 붙은 필드 외에도 추가적으로 데이터를 조회할 수 있도록 한다. 나는 accessToken을 이용하여 refreshToken을 저장하고 조회할 것이기 때문에 accessToken에 해당 어노테이션을 붙여주었다.
RefreshTokenRepository
@Repository public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> { Optional<RefreshToken>findByAccessToken(String accessToken); }
- Redis에서 객체를 조회할 Repository를 선언한다.
- CrubRepository는 Repository를 확장한 인터페이스로써 JPA Repository를 사용하지 않은 이유는 간단한 조회 기능만 필요하기 때문에 해당 Repository를 사용하였다. 만약 정렬이나 페이징 처리가 필요하면 JpaRepository를 상속받아 구현하면 된다.
CrudRepository와 JpaRepository의 차이
회사 코드를 온보딩 기간 중 살펴보던 중 CrudRepositry를 구현한 Repository가 있어 왜 JpaRepsotiory 대신 사용하셨을까라는 궁금증으로 해당 차이점을 공부하기 시작했다.
velog.io
Access, RefreshToken 발급
AccessToken 발급
@Component @RequiredArgsConstructor public class JwtTokenProvider { private final UserDetailService userDetailService; private final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class); private final RefreshTokenRepository refreshTokenRepository; private final UserRepository userRepository; @Value("${spring.jwt.secret}") private String secretKey; private SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); @Value("${spring.jwt.access_exp_time}") private Long accessTokenExpTime; @Value("${spring.jwt.refresh_exp_time}") private Long refreshTokenExpTime; @PostConstruct protected void init() { secretKey = Encoders.BASE64.encode(key.getEncoded()); this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); } public String createAccessToken(String email, String role) { Claims claims = Jwts.claims().setSubject(email); claims.put("role", role); Date now = new Date(); String accessToken = Jwts.builder() .setClaims(claims) //데이터 .setIssuedAt(now) // 토큰 발행 일자 .setExpiration(new Date(now.getTime() + accessTokenExpTime)) //만료 기간 .signWith(key) //secret 값 .compact(); // 토큰 생성 return accessToken; } }
- 사용자의 이메일, 권한을 이용해 AccessToken을 발급하였다.
RefreshToken 발급
public String createRefreshToken(Long id, String accessToken){ Claims claims = Jwts.claims().setSubject(Long.toString(id)); Date now = new Date(); String refreshToken = Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + refreshTokenExpTime)) .signWith(key) .compact(); // redis에 refreshToken 객체 저장 refreshTokenRepository.save(new RefreshToken(id, accessToken, refreshToken)); return refreshToken; }
- refreshToken은 사용자 정보는 담을 필요가 없어서 사용자의 id(pk)만을 이용하여 생성하였다.
- 사용자의 id, accessToken, refreshToken을 이용해 객체를 생성하고 redis에 저장한다.
- 생성한 refreshToken을 리턴한다.
Login Service
@Transactional public TokenDto login(UserLoginDto userLoginDto){ User findUser = userRepository.findByEmail(userLoginDto.getEmail()).orElse(null); TokenDto tokenDto; if (findUser != null && passwordEncoder.matches(userLoginDto.getPassword(), findUser.getPassword())){ // accessToken 발급 tokenDto = TokenDto.builder() .accessToken(jwtTokenProvider.createAccessToken(findUser.getEmail(), findUser.getRole().name())) .build(); // refreshToken 발급 String refreshToken = jwtTokenProvider.createRefreshToken(findUser.getUser_id(), tokenDto.getAccessToken()); logger.info("email : " + findUser.getEmail() + "Login Success"); logger.info("accessToken : " + tokenDto.getAccessToken()); logger.info("refreshToken : " + refreshToken); }else{ logger.info("email : " + userLoginDto.getEmail() + "Login Failed"); tokenDto = TokenDto.builder().build(); } return tokenDto; }
- 사용자가 로그인이 성공하면 access, refresh Token 발급 함수를 호출하여 토큰을 발급한다.
- refreshToken은 Redis에 저장이 되었으므로 AccessToken만 TokenDto에 담아서 Controller로 전달한다.
LoginController
@PostMapping(value = "/login") public String userLogin(UserLoginDto loginDto, HttpServletResponse response){ TokenDto tokenDto = userService.login(loginDto); Cookie cookie = new Cookie("Bearer", tokenDto.getAccessToken()); cookie.setPath("/"); cookie.setSecure(true); cookie.setHttpOnly(true); response.addCookie(cookie); return "redirect:/"; }
- Cookie에 Bearer(key) : accessToken(value) 형태로 저장하여 return 한다.
AccessToken 재발급
토큰 유효성 검증
public enum TokenValid { VALID, TIMEOUT, UNSUPPORTED, EX }
public TokenValid validateToken(String token) { TokenValid tokenValid; try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); logger.info("Token is Valid"); tokenValid = TokenValid.VALID; } catch (ExpiredJwtException e) { logger.info("Token is TimeOut"); tokenValid = TokenValid.TIMEOUT; } catch (UnsupportedJwtException e) { logger.info("Token is Unsupported"); tokenValid = TokenValid.UNSUPPORTED; } catch (Exception e) { logger.info("Token Exception"); tokenValid = TokenValid.EX; } return tokenValid; }
- token의 유효성 검증 상태를 위한 enum과 함수를 정의하였다
- Security filter에서 validateToken함수를 호출해 토큰을 검증할 예정이다.
쿠키에서 토큰 추출
public String resolveToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("Bearer".equals(cookie.getName())) { return cookie.getValue(); } } } return null; }
- 쿠키에 토큰값을 저장하도록 설계했기 때문에 쿠키에서 토큰을 추출하는 함수를 작성하였다.
- Security filter에서 validateToken을 호출하기 전에 token값을 추출하는 용도로 사용될 예정이다.
RefreshToken 추출
@Transactional(readOnly = true) public RefreshToken getRefreshToken(String accessToken){ RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(accessToken).orElse(null); return refreshToken; }
- AccessToken을 이용하여 RefreshToken 객체를 조회하는 기능을 제공하는 함수이다.
- Security filter에서 accessToken이 만료되었다면 accessToken을 이용해 refreshToken을 조회할 때 사용될 예정이다.
AccessToken 재발급
@Transactional public void removeRefreshToken(String accessToken){ refreshTokenRepository.findByAccessToken(accessToken) .ifPresent(refreshToken -> refreshTokenRepository.delete(refreshToken)); } @Transactional public String reCreateAccessToken(String originAccessToken, RefreshToken refreshToken){ Long userId = refreshToken.getId(); User user = userRepository.findById(userId).orElse(null); String newAccessToken = createAccessToken(user.getEmail(), user.getRole().name()); removeRefreshToken(originAccessToken); refreshTokenRepository.save(new RefreshToken(userId, newAccessToken, refreshToken.getRefreshToken())); return newAccessToken; }
- reCreateAccessToken
- refreshToken 객체에 저장되어 있는 유저의 id를 이용하여 user 정보를 조회하고 이를 기반으로 새로운 accessToken을 발급해 준다.
- 이때 기존에 accessToken 정보를 가지고 있는 refreshToken 객체는 삭제하여 주고 새로운 accessToken을 가진 객체로 저장하여 정보를 갱신한다. (삭제 말고 업데이트도 가능할 듯!)
- removeRefreshToken
- accessToken으로 refreshToken 객체를 찾아 삭제하여 준다.
Security Filter Chain
@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { /** 1 **/ logger.info("AccessToken 검증 시작"); String accessToken = jwtTokenProvider.resolveToken(request); TokenValid accessTokenValid = jwtTokenProvider.validateToken(accessToken); if (accessTokenValid == TokenValid.VALID){ /** 2 **/ Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); }else if (accessTokenValid == TokenValid.TIMEOUT){ logger.info("Start recreate AccessToken"); /** 3 **/ RefreshToken refreshToken = jwtTokenProvider.getRefreshToken(accessToken); if (jwtTokenProvider.validateToken(refreshToken.getRefreshToken()) == TokenValid.VALID){ String newAccessToken = jwtTokenProvider.reCreateAccessToken(accessToken, refreshToken); System.out.println(newAccessToken); Cookie cookie = new Cookie("Bearer", newAccessToken); cookie.setPath("/"); cookie.setSecure(true); cookie.setHttpOnly(true); response.addCookie(cookie); Authentication authentication = jwtTokenProvider.getAuthentication(newAccessToken); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }
- 1번 함수
- request에서 앞서 토큰을 추출하고 토큰을 검증한다.
- 2번 코드
- token 유효성 검증결과 Valid라면 유효하다는 의미이기 때문에 authentication을 발급하여 Security에 저장한다.
- 3번 코드
- token 유효성 검증결과 TIMEOUT이라면 token 값을 이용해 RefreshToken을 가져온다
- RefreshToken객체 내에 refreshToken의 유효성을 검증하여 유효하면 token 재생성 로직들을 호출한다.
- token 재생성이 완료되면 cookie에 저장하고 새로운 token으로 authentication을 발급하여 Security에 저장한다.
결과
테스트를 위해 accesstoken의 만료 시간을 10초로 설정하여 10초가 지난 뒤 재발급되도록 하였다.
최초 로그인
최초 login log redis 저장 상태 - 10:28:17초에 최초 로그인을 시도하였고 access, refresh token을 발급한 log를 확인할 수 있다.
- redis에는 동일한 accessToken값이 저장되었다.
토큰 만료 후
재발급 Log redis 저장 상태 - 10:28:46초에 accesstoken 검증을 시작하였고 Token is TimeOut이라는 로그 이후에 recreate AccessToken을 시작하는 로그를 확인할 수 있다.
- 그 이후 token이 다시 유효하게 되었고 accesstoken값이 기존과 변경되었음을 확인할 수 있다.
'취준 > Project' 카테고리의 다른 글
Naver MAP API를 이용해 지도 구현 (1) 2024.06.16 EC2에 Redis 설치후 연동하기 (0) 2024.06.08 Spring Boot LazyInitializationException (0) 2024.05.29 Spring Boot 개발 환경 분리하기(properties File) (1) 2024.05.23 Reason: Failed to determine a suitable driver class (2) 2024.05.01