SPRING

RedisRepository 이용하여 refreshToken 관리하기

개발하는고양이 2024. 2. 7. 01:42
반응형

설정

라이브러리 추가

스프링 부트에서는 spring data redis 라이브러리가 필요하므로 build.gradle 에 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

이를 통해 Lettuce, Jedis라는 두개의 오픈소스 라이브러리를 사용할 수 있다.

Lettuce는 별도 설정 필요x, Jedis 는 별도의 의존성이 필요하다.

Redis를 설정하는 두가지 방식

spring data redis가 제공하는 방법은 2가지이다.

- RedisTemplate

- RedisRespository

 

spring data redis에 내장되어 있는 lettuce를 사용하고, 이번에는 RedisRepository을 이용해보자.

 

Config 파일

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {

    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;

    // Lettuce 사용
    @Bean
    public RedisConnectionFactory redisConnectionFactory(){

        return new LettuceConnectionFactory(host,port);
    }
}

Redis와 연결하기 위한 RedisConnectionFactory를 빈으로 등록해준다.

 

개발중인 프로젝트에 도입하기

사용자가 로그인을 요청하면,

검증 후, accessToken & refreshToken을 발급한다.

refreshToken은 redis에 저장, accessToken은 사용자에게 반환하여 accessToken을 인증 용도로 사용하고 있다.

 

 

 

accessToken이 만료가 되면

accessToken으로 refreshToken을 찾아서

존재하면 accessToken 만료기간을 갱신, 존재하지 않는다면 다시 로그인 해야한다.

 

 

사용자가 로그아웃을 요청할 경우,

refreshToken은 redis에서 삭제

accessToken을 blacklist에 넣어 추후에 해당 토큰으로 접근할시 차단한다.

 

 

Redis에 넣을 Class 생성

redis에 저장할 값은 크게, refreshToken과 blacklist이다.

RedisToken

@Getter
@AllArgsConstructor
@RedisHash(value = "refreshtoken", timeToLive = 60*60*24*3)
public class RefreshToken {
    @Id
    private String refreshToken;
    @Indexed
    private String accessToken;
    private Long memberId;

    @Transactional
    public void updateRefreshToken(String accessToken){
        this.accessToken = accessToken;
    }
}

 

@RedisHash (value = "refreshtoken", timeTolive=60*60*24*7)

RedisHash어노테이션은 Domain Object를 Redis Hash 자료구조로 변환해주도록 한다.

 

value : redis에서 String 타입의 아이디를 key로 사용하는 여러개의 엔티티가 있을 때 key가 중복될 수 있으므로

value 값에 prefix를 두어서 구분지을 수 있다.

이렇게 되면 실제 redis 값에는 "refreshtoken:refresh토큰값" 으로 key 값이 지정된다.

timeTolive :  redis에 해당 데이터가 저장될 유효기간을 의미한다. (7일로 지정)

 

@Indexed 

@Indexed가 붙여진 객체로도 값을 조회할 수 있도록 한다.

 

RefreshTokenRepository

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    Optional<RefreshToken> findByAccessToken(String accessToken);
}

 

로그인이 되면 refreshToken은 redis에 저장이 된다.

LogoutToken

로그아웃을 요청한 accessToken을 여기에 저장한다.

accessToken 유효시간을 30분으로 설정해둬서

일단 이것도 30분으로 설정했다..

@Getter
@AllArgsConstructor
@RedisHash(value = "blacklist", timeToLive = 60*30)
public class LogoutToken {
    @Id
    private String id;
    @Indexed
    private String accessToken;
}

LogoutRepository

@Repository
public interface LogoutTokenRepository extends CrudRepository<LogoutToken, String> {
    Optional<LogoutToken> findByAccessToken(String token);
}

 

 

 

 

로그인 로직짜기

AuthController

@PostMapping("/signin")
    public ResponseEntity<String> signIn (@RequestBody Auth.SignIn form, HttpServletResponse response){
        var member = MemberDto.from(authService.authenticate(form));
        var accessToken = tokenProvider.generateToken(member.getUsername(), member.getRoles());
        tokenProvider.generateRefreshToken(accessToken, member.getId());
        response.setHeader(TOKEN_HEADER, TOKEN_PREFIX+accessToken);
        return ResponseEntity.ok(accessToken);
    }

 

TokenProvider

public void generateRefreshToken(String accessToken, Long memberId){
        RefreshToken token =
                new RefreshToken(UUID.randomUUID().toString(),accessToken,memberId);
        refreshTokenRespository.save(token);
    }

 

로그아웃 로직짜기

AuthController

@PostMapping("/logout")
    public ResponseEntity<String> logout (HttpServletRequest request){
        var token = jwtAuthenticationFilter.resolveTokenFromRequest(request);
        authService.logout(token);
        return ResponseEntity.ok("로그아웃");
    }

 

AuthService

public void logout(String token) {
        log.info(token);
        RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(token)
                .orElseThrow(()-> new CustomException(ErrorCode.TOKEN_NOT_FOUND));
        refreshTokenRepository.deleteById(refreshToken.getRefreshToken());
        logoutTokenRepository.save(new LogoutToken(UUID.randomUUID().toString(),token));
    }

 

JwtAuthenticationFilter

 @Override
    protected void doFilterInternal
            (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if(SecurityContextHolder.getContext().getAuthentication() == null){
            String token = this.resolveTokenFromRequest(request);
            if(StringUtils.hasText(token)){
                // 이미 로그아웃한 토큰인지
                if(tokenProvider.isLogout(token)){
                    throw new CustomException(ErrorCode.TOKEN_EXPIRED);
                }
                // 토큰이 만료됐다면
                if(!tokenProvider.validateToken(token)){
                    RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(token)
                            .orElseThrow(()-> new CustomException(ErrorCode.TOKEN_EXPIRED));

                    Member member = memberRepository.findById(refreshToken.getMemberId())
                            .orElseThrow(()-> new CustomException(ErrorCode.USER_NOT_FOUND));
                    // 토큰 재발급
                    token = tokenProvider.generateToken(member.getNickname(),member.getRoles());
                    refreshTokenRepository.save(new RefreshToken(refreshToken.getRefreshToken(),token,refreshToken.getMemberId()));
                }
                // 토큰 유효성 검증
                Authentication auth = this.tokenProvider.getAuthentication(token);
                log.info(String.format("[%s] -> %s",
                        this.tokenProvider.getUsername(token),request.getRequestURI()));
                SecurityContextHolder.getContext().setAuthentication(auth);

            }
        }
        // filter가 연속적으로 실행이 되도록
        filterChain.doFilter(request,response);
    }

 

 

반응형