본문 바로가기
프로젝트

GiftFunding) Spring Boot와 Redis를 활용한 Resfresh Token 구현

by son_i 2023. 11. 26.
728x90

기존 Access Token 방식의 문제점

JWT를 기반으로 유효한 사용자가 로그인을 하면 토큰을 내려주고 권한이 필요한 접근에 대해서 header에 토큰을 포함해서 동작을 할 수 있다. 여기서 토큰은 세션과 다르게 stateless하다. == 서버가 상태를 보관하지 않는다.

 

  따라서 한 번 발급한 토큰이 탈취되어도 서버에서는 토큰 만료시까지 기다리는 것 말고는 별다른 조치를 할 수가 없다.

 

- Refresh Token ? ) 

똑같은 JWT 지만 재발급에 관여하는 토큰. (Access Token은 접근에 관여하는 토큰)

 

 Refresh Token은 기존에 클라이언트가 가지고 있던 Access Token이 만료되었을 때 새로 발급받기 위해서 사용한다.

 Access Token에 비해 긴 유효시간을 가진다. 

 Refresh Token을 사용함으로써 Access Token의 유효 시간을 더 짧게, 자주 재발급 하도록 만들어 보안을 강화하면서 사용자에게 잦은 로그아웃 경험을 주지 않도록 한다.

 

  ex) Access Token 30분 이내, Refresh Token은 2주 정도로 설정 (서비스 성격에 따라 적절하게 설정)

 

 

- Access / Refresh Token 재발급 원리

1. 클라이언트가 로그인 과정을 거치면 서버는 Refresh Token과 Access Token을 발급한다.

     Refresh Token만 서버 쪽의 DB에 저장하고, Refresh Token과 Access Tokne을 쿠키/웹 스토리지에 저장한다.

 

2. 사용자가 인증이 필요한 API에 접근 시 토큰을 검사한다.

     토큰을 검사함과 동시에 각 경우에 대해서 토큰의 유효기간을 확인하여 재발급 여부를 결정한다.

  • case1 : access token과 refresh token 모두가 만료된 경우 에러 발생 (재 로그인하여 둘다 새로 발급)
  • case2 : access token은 만료됐지만, refresh token은 유효한 경우   refresh token을 검증하여 access token 재발급
  • case3 : access token은 유효하지만, refresh token은 만료된 경우  access token을 검증하여 refresh token 재발급
  • case4 : access token과 refresh token 모두가 유효한 경우 정상 처리

* Refresh Token을 검증하여 access token 재발급

   : 클라이언트 (쿠키/웹스토리지)에 저장되어있는 refresh token과 서버 db에 저장되어 있는 refresh token을 비교하여 일치하면 access token을 재발급한다.

 

* access token을 검증하여 refresh token 재발급

  : access token이 유효하다 == 이미 인증되었다. -> 바로 refresh token 재발급

 

3. 로그아웃 하면 Access Token과 Refresh Token을 모두 만료시킨다.

 

 

- Refresh Token 인증 과정

1. 사용자가 ID , PW를 통해 로그인.

2. 서버에서는 회원 DB에서 값을 비교

3~4. 로그인이 완료되면 Access Token, Refresh Token을 발급한다. 이때 회원DB에도 Refresh Token을 저장해둔다.

5. 사용자는 Refresh Token은 안전한 저장소에 저장 후, Access Token을 헤더에 실어 요청을 보낸다.

6~7. Access Token을 검증하여 이에 맞는 데이터를 보낸다.

8. 시간이 지나 Access Token이 만료됐다.

9. 사용자는 이전과 동일하게 Access Token을 헤더에 실어 요청을 보낸다.

10~11. 서버는 Access Token이 만료됨을 확인하고 권한없음을 신호로 보낸다.

Tip

Access Token 만료가 될 때마다 계속 과정 9~11을 거칠 필요는 없다.
사용자(프론트엔드)에서 Access Token의 Payload를 통해 유효기간을 알 수 있다.
따라서 프론트엔드 단에서 API 요청 전에 토큰이 만료됐다면 곧바로 재발급 요청을 할 수도 있다.

12. 사용자는 Refresh Token과 Access Token을 함께 서버로 보낸다.

13. 서버는 받은 Access Token이 조작되지 않았는지 확인한후, Refresh Token과 사용자의 DB에 저장되어 있던 Refresh Token을 비교한다. Token이 동일하고 유효기간도 지나지 않았다면 새로운 Access Token을 발급해준다.

14. 서버는 새로운 Access Token을 헤더에 실어 다시 API 요청 응답을 진행한다. 

 

출처: https://inpa.tistory.com/entry/WEB-📚-Access-Token-Refresh-Token-원리-feat-JWT#refresh_token이_왜_필요한가 [Inpa Dev 👨‍💻:티스토리]

 

- Refresh Token의 형태

JWT일 수도, 간단한 문자열이나 UUID일 수도 있음.

 

  - jwt 형태의 Refresh Token

   : Refresh Token이 JWT라면 Access Token과 똑같이 stateless하고, 토큰 자체에 데이터를 담을 수 있다.

   이 경우 Refresh Token의 유효성을 검사하기 위해 데이터베이스에 별도로 액세스하지 않아도 된다.

   -> 서버의 부하가 상대적으로 적음.

   -> Access Token과 마찬가지로 Refresh Token을 서버에서 제어할 수 없다는 단점이 있다.

      Refresh Token 을 탈취당한 상황에서 토큰을 강제로 무효 시킬 수가 없음.

 

 - JWT 형태가 아닌 Refresh Token

  :  Refresh Token으로 random string 또는 UUID 등을 사용하면 토큰을 사용자와 매핑되도록 데이터베이스에 저장해야한다. 이런 경우 Refresh Token 사용시 데이터 베이스에 액세스 해야한다. 하지만, 사용자를 강제로 로그아웃 시키거나 차단할 수 있게된다. 또한 Refresh Token 이 탈취됐을 경우 즉시 무효화 시킬 수 있다.

 


* Redis란 ? )

: Remote Dictionary Server로 key-value의 비정형 데이터를 저장하고 관리하기 위한 오픈소스 기반 비관계형 DBMS 

   외부      key-value  서버

 

- 특징

 1 . 인메모리 데이터 스토어 : 원래 인메모리 기반은 휘발성이지만 Redis는 영속성을 지원한다.

   -> 데이터를 디스크에 쓰는 구조가 아니라 메모리에서 데이터를 처리하기 때문에 속도가 빠름.

 

 2. key-value 구조이기 때문에 쿼리를 사용할 필요가 없다.

 

 3. String, Lists, Sets Sorted Sets, Hashes등 다양한 형태의 자료구조(컬렉션)를 지원한다.

 

 4. Single Threades : 한 번에 하나의 명령만 처리한다.

 

 5. 데이터가 영구적이어도 RDBMS만큼은 아니라 중요한 정보 저장보다는 주로 캐시 서버 용도로 많이 사용한다.

 

 6. MySQL같은 DB와 비교할 때 데이터를 저장한다는 것만 같고 데이터의 저장 위치, 저장 바익, 저장할 데이터 타입에 차이가 있음.


- Redis 설정

Lettuce vs Jedis

 

Spring  Data Redis에서 사용할 수 있는 Redis 구현체는 크게 Lettuce와 Jedis가 있다.

'spring-boot-starter-data-redis'를 사용하면 별도의 의존성 설정 없이 Lettuce를 사용할 수 있는데 Jedis는 별도의 설정이 필요하다.

 

Jedis보다 Lettuce를 쓰자 https://jojoldu.tistory.com/418

* jedis는 thread-safe하지 않기 때문에 jedis-pool을 사용해야한다. 그러나 비용이 증가하기 때문에 lettuce를 많이 사용

* Lettuce는 Netty (비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Redis 클라이언트입니다.
비동기로 요청을 처리하기 때문에 고성능을 자랑하므로 Lettuce를 사용하자.


- Redis를 사용하는 방법

스프링 부트에서 Redis를 사용하는 방법에는 두 가지가 있다.

Repository 인터페이스를 정의하는 방법과 Redis Template를 사용하는 방법

 

- Redis Repository

Repository 인터페이스를 정의하는 방법은 Spring Data JPA를 사용하는 것과 비슷하다.

Redis는 많은 자료구조를 지원하는데, Repository를 정의하는 방법은 Hash 자료구조로 한정하여 사용할 수 있다. 

Repository를 사용하면 객체를 Redis의 Hash 자료구조로 직렬화하여 스토리지에 저장할 수 있다.

 

- Redis Template

Redis Template는 Redis 서버에 커맨드를 수행하기 위한 고수준의 추상화(high-level abstraction)을 제공한다.


구현  목표

JWT를 이용한 인증/인가 구현에서 Refresh Token 을 Redis에 저장하여 관리. Redis Tempklate 이용

 

1. build.gradle에 의존성 추가

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

 

2. application.properties 설정

spring.redis.host=localhost
spring.redis.port=6379

 

3. <Redis Config>

RedisTemplate을 사용하기 위해서 @Configuration을 통해서 redisTemplate을 빈으로 등록해야함.

package com.soeun.GiftFunding.config;

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.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class RedisConfig {
    private final String redisHost;
    private final int redisPort;

    public RedisConfig(
        @Value("${spring.redis.host}")final String redisHost,
        @Value("${spring.redis.port}")final int redisPort) {
        this.redisHost = redisHost;
        this.redisPort = redisPort;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate =
            new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

- redisConnectionFactory()  메소드 : 레디스와 커넥션 맺을 수 있는 커넥션 팩토리만 설정된 상태. RedisConnectionFacotry 인터페이스를 통하여 LettuceConnectionFactory를 생성하여 반환한다.

 

- redisTemplate() 메소드 : 다른 직렬화 방식을 사용하기 위해 RedisTemplate bean을 생성.

  * bean 이름을 redisTemplate으로 하면 자동설정(RedisAutoOcnfiguration)이 redisTemplate은 생성하지 않는다.

 RedisTemplate : java Object를 redis에 저장하는 경우 사용.
  - Redis서버에 Redis 커맨드를 수행하기 위한 high-level-abstractions를 제공한다.
  - Object 직렬화, Connection management를 수행한다.
  - Redis 서버에 데이터 CRUD를 위해, Redis의 다섯가지 데이터 유형에 대한 Operation Interface를 제공한다.

opsForValue : Strings를 쉽게 Serialize / Deserialize 해주는 Interface
opsForList : List를 쉽게 Serialize / Deserialize 해주는 Interface
opsForSet : Set를 쉽게 Serialize / Deserialize 해주는 Interface
opsForZSet : ZSet를 쉽게 Serialize / Deserialize 해주는 Interface
opsForHash : Hash를 쉽게 Serialize / Deserialize 해주는 Interface

 

4. <RefreshToken>

@Getter
@AllArgsConstructor
public class RefreshToken {
    @Id
    private String refreshToken;

    private String mail;
}

- RefreshToken은 리프레시 토큰과 사용자의 메일정보를 가지고 있는 객체이다.

 이 객체는 레디스에 저장되어 리프레시 토큰을 관리하는데 사용된다.

 

5. <RefreshTokenRepository>

Redis Template에서는 Repository를 인터페이스로 정의하지 않고 직접 구현해서 사용한다.

@Repository
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenRepository {
    private final RedisTemplate redisTemplate;

    public void save(final RefreshToken refreshToken) {
        log.info("save");
        ValueOperations<String, String> valueOperations =
            redisTemplate.opsForValue();
        valueOperations.set(refreshToken.getRefreshToken()
            , refreshToken.getMail());

        redisTemplate.expire(
            refreshToken.getRefreshToken(), 180L, TimeUnit.SECONDS);
    }

    public Optional<RefreshToken> findByMail(
        final String refreshToken) {

        ValueOperations<String, String> valueOperations =
            redisTemplate.opsForValue();
        String mail = valueOperations.get(refreshToken);

        if (Objects.isNull(mail)) {
            return Optional.empty();
        }

        return Optional.of(new RefreshToken(refreshToken, mail));
    }
}

 

- save() 메소드 : 

RestTemplate은 Redis 서버에 CRUD를 위해 Redis의 다섯가지 데이터 유형에 대한 Operation interface를 제공한다. 해당 메소드를 이용해 opsForValue()로 String을 직렬화해서 Redis에 set(저장)

 

redisTemplate.expire로 키인 refreshToken의 만료시간 세팅. 현재 테스트를 위해 60초로 지정.

 

- findByMail() 메소드 : refreshToken(키 값)으로 user의 이메일을 조회. 

 

6. <수정사항>

기존 AccessToken 만을 발급하던 부분에서 수정

- TokenProvider -> 수정 ATK 와 RTO의 토큰 타입을  claims에 넣어줌
https://sol-devlog.tistory.com/22여기 참조

@Component
@RequiredArgsConstructor
@Slf4j
public class TokenProvider {

    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000L * 60L * 60L; //1분
    private static final String TOKEN_TYPE = "token_type";
    private final RefreshTokenRepository refreshTokenRepository;

    @Value("${spring.jwt.secret}")
    private String secretKey;

    public String generateRefreshToken(String mail) {
        Claims claims = Jwts.claims().setSubject(mail);
        claims.put(TOKEN_TYPE, "RTK");

        String token = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(new Date())
            .signWith(SignatureAlgorithm.HS512, this.secretKey)
            .compact();

        refreshTokenRepository.save(
            new RefreshToken(token, mail)
        );
        return token;
    }

    public String generateAccessToken(String mail) {
        Claims claims = Jwts.claims().setSubject(mail);
        claims.put(TOKEN_TYPE, "ATK");

        Date now = new Date();
        Date expiredDate = new Date(now.getTime()
            + ACCESS_TOKEN_EXPIRE_TIME);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(expiredDate)
            .signWith(SignatureAlgorithm.HS512, this.secretKey)
            .compact();
    }

    public String reIssueAccessToken(String refreshToken) {
        RefreshToken token =
            refreshTokenRepository.findByMail(refreshToken)
                .orElseThrow(() ->
                    new TokenException(ErrorType.REFRESHTOKEN_EXPIRED));

        return this.generateAccessToken(token.getMail());
    }
    public String getTokenType(Claims claims) {
        return (String) claims.get(TOKEN_TYPE);
    }

    public String getMail(String jwt) {
        return this.parseClaims(jwt).getSubject();
    }

    public Claims parseClaims(String jwt) {
        try {
            return Jwts.parser()
                .setSigningKey(this.secretKey)
                .parseClaimsJws(jwt).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

 

- UserServiceImpl singIn메소드, refresh Token으로 AccessToken을 재발급 해줄 reissue 메소드

@Override
    public Response signIn(Signin.Request request) {
        User user = userRepository.findByEmail(request.getEmail())
            .orElseThrow(() -> new UserException(USER_NOT_FOUND));

        validatedPassword(request, user);

        String mail = user.getEmail();

        return Response.builder()
            .accessToken(tokenProvider.generateAccessToken(mail))
            .refreshToken(tokenProvider.generateRefreshToken(mail))
            .build();
    }
    
    @Override
    public Reissue.Response reissue(Reissue.Request request) {
        return Reissue.Response.builder()
            .accessToken(tokenProvider.reIssueAccessToken(request.getRefreshToken()))
            .refreshToken(request.getRefreshToken())
            .build();
    }

 

 

 

* 아직 해결하지 못 한 것

토큰이 만료됐을 경우 TokenException을 터칠려고 했는데 그 부분이 계속 null로 나온다.

-> 필터내 예외처리에 관한 부분으로 GiftFunding)필터 내 예외 처리 (tistory.com) 해결 함

 

참고

https://hudi.blog/refresh-token-in-spring-boot-with-redis/