Study/Pet-Clinic-Project

[Study-13, 14주차] RefreshToken + Redis(NoSQL) 적용

soohykeee 2023. 7. 4. 16:42
728x90

 

 

[Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ②

 

[Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ②

[Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ① [Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ① [Study-10, 11주차] SpringSecurity 적용 [Study-10, 11주차] SpringSecurity 적용 [Study-8, 9주차] @Re

soohykeee.tistory.com

 


 

그 전에는 JWT를 이용한 AccessToken 을 통한 인증방식을 구현해줬다. 하지만 많은 곳에서 보안 향상을 위해서 refreshToken을 사용해준다. 그렇다면 이 refreshToken이란 대체 무엇일까? 해당 내용은 아래의 글에 정리해두었다.

이번에는 이러한 refeshToken을 프로젝트에 적용해보겠다.

[개념] RefreshToken 정리

 

[개념] RefreshToken 정리

JWT를 이용한 로그인을 구현하는 예제를 해보다, AccessToken과 RefreshToken을 구분해서 사용하는 이유와 정확한 개념을 이해하고 싶어 찾아보게 되었다. 다들 refreshToken을 사용하는 이유는 더 높은 보

soohykeee.tistory.com

 

 

구현 과정

출처 : https://velog.io/@junho5336/토큰-탈취-고려하기-Refresh-Token

RefreshToken의 인증을 간단하게 살펴보면 다음과 같다.

  1. AccessToken을 검증하여 유저 확인
  2. DB에서 해당 유저 RefreshToken을 가져오기
  3. RefreshToken을 비교하여 검증

이렇게 DB에 RefreshToken을 저장하고 가져올 때, 만료시간을 관리해주기 위해서는 TTL (Time To Live)를 사용하는데, 지금 현재 우리가 사용하고 있는 RDBMS - MySQL에서는 어려움이 존재한다. 그렇기에 해당 RefreshToken을 관리해주기 위해서 많은 개발자들이 NoSQL을 사용해주고 있다. 그렇기에 해당 프로젝트에서는 Redis를 적용해 줄 것이다.

 

여기서 위의 RefreshToken의 정리에서 작성한 내용처럼 2가지의 방법이 존재한다.

  1. RefreshToken을 JWT로 발급하여 관리
  2. NoSQL의 TTL을 이용하여 RefreshToken을 일정 시간만큼 저장

해당 내용에 대해서는 위의 첨부한 글에 정리가 되어있다. 여기서 해당 프로젝트에서는 2번째 방법으로 UUID를 이용하여 랜덤한 키 값을 통해 RefreshToken을 생성하고 이를 NoSQL에서 관리하는 방식으로 해줄것이다.

 


 

 

 

docker-redis 설정 참고 - ①

docker-redis 설정 참고 - ②

우선 위의 글들을 참고하여 docker에 redis를 설치해주었다. 그 후, 프로젝트에서 redis를 사용해주기 위해서 build.gradle에 dependency를 추가해주었다. application.yml에도 redis에 대한 설정을 추가해주었다.

    // NoSQL - Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
spring:
  data:
    redis:
      port: 6379
      host: localhost

 

현재 SpringBoot가 버전이 업그레이드가 되면서, redis에 대한 configuration을 작성해주지 않아도 된다고 한다. 하지만 설정 정보에 대해서 알고 있으면 좋을 것 같아서 따로 RedisConfiguration 클래스를 추가해주었다.

RedisConfiguraiton

package kr.co.jshpetclinicstudy.infra.config;

@Configuration
public class RedisConfiguration {

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }
}

 

위의 코드는 Spring Boot에서 Redis를 사용하기 위한 Redis 연결 및 설정을 구성하는 클래스이다.
@Value를 통해서 application.yml에서 지정해주었던 redis에 대한 port와 host에 대한 정보를 가져온다.

  1. redisConnectionFactory() : Redis 연결 팩토리를 생성하는 빈(Bean) 메서드이다. LettuceConnectionFactory를 사용하여 Redis 호스트와 포트로 연결을 설정한다. 생성된 Redis 연결 팩토리는 Spring의 RedisTemplate에서 사용된다.

  2. redisTemplate() : RedisTemplate을 생성하는 빈(Bean) 메서드이다. redisConnectionFactory()를 사용하여 Redis 연결 팩토리를 설정한다. 생성된 RedisTemplate은 Redis 데이터에 액세스하기 위한 기능을 제공한다.

 


 

Token

package kr.co.jshpetclinicstudy.persistence.entity;

@Getter
@RedisHash("refreshToken")
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Token {

    @Id
    @JsonIgnore
    private Long id;

    private String refreshToken;

    @TimeToLive(unit = TimeUnit.SECONDS)
    private Integer expiration;

    public void setExpiration(Integer expiration) {
        this.expiration = expiration;
    }
}

 

위의 코드는 Token이라는 Redis에 저장될 데이터 모델을 나타내는 클래스이다.

  1. @RedisHash("refreshToken"): Redis에 저장될 데이터의 해시(Hash) 이름을 "refreshToken"으로 설정

  2. id 필드를 해당 데이터의 식별자로 지정한다. 해당 필드는 Redis에 저장되지만, JSON 직렬화 시에는 @JsonIgnore 어노테이션에 의해 무시가 된다.

  3. refreshToken: Redis에 저장될 refreshToken
  4. @TimeToLive(unit = TimeUnit.SECONDS) : Redis에 저장된 데이터의 유효 기간을 설정한다. expiration 필드에 대한 설정으로, TimeUnit.SECONDS (초) 단위로 지정된다. (TimeUnit.DAYS 등 여러 단위로 맞춰 조정이 가능하다.)

 

TokenRepository

package kr.co.jshpetclinicstudy.persistence.repository;

public interface TokenRepository extends CrudRepository<Token, Long> {

}

 

TokenDto

package kr.co.jshpetclinicstudy.service.model.request;

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {

    private String accessToken;

    private String refreshToken;

}

 


 

Member - RefreshToken 필드 추가

package kr.co.jshpetclinicstudy.persistence.entity;

@Entity
@AttributeOverride(name = "id", column = @Column(name = "member_id", length = 4))
@Getter
@NoArgsConstructor
@Table(name = "tbl_members")
public class Member extends BaseEntity{

    @Column(name = "identity", unique = true)
    @NotNull
    private String identity;

    @Column(name = "password")
    @NotNull
    private String password;

    @Column(name = "name", length = 100)
    @NotNull
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "role")
    private Role role;

    @Column(name = "refresh_token")		// 추가
    private String refreshToken;

    @Builder
    public Member(String name,
                  String identity,
                  String password,
                  Role role) {
        this.name = name;
        this.identity = identity;
        this.password = password;
        this.role = role;
    }

    public void updateMember(MemberRequestDto.UPDATE update) {
        this.name = update.getName();
        this.role = Role.valueOf(update.getRole());
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;		// 추가
    }
}

 

MemberResponseDto - 수정

package kr.co.jshpetclinicstudy.service.model.response;

public class MemberResponseDto {

    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class READ {

        private Long memberId;

        private String identity;

        private String name;

        private String role;

        private TokenDto token;		// 수정
    }
}

 

MemberService - 수정

package kr.co.jshpetclinicstudy.service;

@Service
@RequiredArgsConstructor
public class MemberService {


    private final TokenRepository tokenRepository;

    // ... 생략

    public MemberResponseDto.READ loginMember(MemberRequestDto.LOGIN login) {
        final Optional<Member> member = memberRepository.findMemberByIdentity(login.getIdentity());

        isMember(member);

        isPassword(login.getPassword(), member.get().getPassword());

        return MemberResponseDto.READ.builder()
                .memberId(member.get().getId())
                .identity(member.get().getIdentity())
                .name(member.get().getName())
                .role(String.valueOf(member.get().getRole()))
                .token(TokenDto.builder()
                        .accessToken(jwtProvider.createToken(member.get().getIdentity(), String.valueOf(member.get().getRole())))
                        .refreshToken(createRefreshToken(member.get()))
                        .build())
                .build();
    }

	// ... 생략

    /*
    ======== Refresh Token ========
     */

    /**
     * RefreshToken 생성
     * Redis 내부에는, refreshToken:memberId : tokenValue 형태로 저장
     *
     * @param member
     * @return
     */
    public String createRefreshToken(Member member) {
        Token token = tokenRepository.save(
                Token.builder()
                        .id(member.getId())
                        .refreshToken(UUID.randomUUID().toString())
                        .expiration(120)
                        .build()
        );

        return token.getRefreshToken();
    }

    public Token validRefreshToken(Member member, String refreshToken) {
        Optional<Token> token = tokenRepository.findById(member.getId());

        isToken(token);

        // redis에 해당 유저의 토큰이 존재하는지 체크
        if (token.get().getRefreshToken() == null) {
            return null;
        } else {
            // refreshToken은 있지만, 만료시간이 얼마 남지 않았다면 만료시간 연장
            // 해당 부분은 만료시간 연장이 아닌, 재발급으로 해줄 지 고민 중
            if (token.get().getExpiration() < 10) {
                token.get().setExpiration(1000);
                tokenRepository.save(token.get());
            }

            // token이 같은지 비교
            if (!token.get().getRefreshToken().equals(refreshToken)) {
                return null;
            } else {
                return token.get();
            }
        }
    }

    public TokenDto refreshAccessToken(TokenDto tokenDto) {
        String identity = jwtProvider.getIdentity(tokenDto.getAccessToken());

        Optional<Member> member = memberRepository.findMemberByIdentity(identity);

        isMember(member);

        Token refreshToken = validRefreshToken(member.get(), tokenDto.getRefreshToken());

        isRefreshToken(refreshToken);

        return TokenDto.builder()
                .accessToken(jwtProvider.createToken(identity, String.valueOf(member.get().getRole())))
                .refreshToken(refreshToken.getRefreshToken())
                .build();

    }

    // ... 생략

    private void isToken(Optional<Token> token) {
        if (token.isEmpty()) {
            throw new NotFoundException(ResponseStatus.FAIL_TOKEN_NOT_FOUND);
        }
    }

    private void isRefreshToken(Token refreshToken) {
        if (refreshToken == null) {
            throw new InvalidRequestException(ResponseStatus.FAIL_LOGIN_NOT_SUCCESS);
        }
    }
}

 

RefreshToken 생성을 할 때 현재 120초로 해주었는데, 추후 테스트를 위해서 짧게 만료시간을 설정해 준 것이다. 또한 현재 요청이 왔을 때 refreshToken이 10초아래로 남았을 경우 재발급이 아닌, 만료시간을 늘려주도록 설정해주었는데 해당 부분은 재발급이 되도록 할지, 현재처럼 만료시간을 늘려줄지 고민 중이다.

 

JwtProvider - 수정

package kr.co.jshpetclinicstudy.infra.jwt;

@RequiredArgsConstructor
@Component
public class JwtProvider {

    @Value("${jwt.secret.key}")
    private String salt;

    private Key secretKey;

    // 만료시간 1hour
	//private final Long exp = 1000L * 60 * 60;
	// 테스트 진행을 위해 1분으로 지정
    private final Long exp = 1000L * 60;

    private final JpaUserDetailsService userDetailsService;
    
    // ... 생략

    // Token 에 담겨있는 유저 Identity get
    public String getIdentity(String token) {

        try {
            Jwts
                    .parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException e) {
            e.printStackTrace();
            return e.getClaims().getSubject();
        } catch (Exception e) {
            e.printStackTrace();
        }

        return Jwts
                .parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // ... 생략
    
}

 

MemberController 

package kr.co.jshpetclinicstudy.controller;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class MemberController {

    private final MemberService memberService;

    // ... 생략

    /**
     * Refresh
     *
     * @param tokenDto
     * @return
     */
    @GetMapping("/refresh")
    public ResponseFormat<TokenDto> refresh(@RequestBody @Valid TokenDto tokenDto) {
        return ResponseFormat.successWithData(ResponseStatus.SUCCESS_OK, memberService.refreshAccessToken(tokenDto));
    }

}

 

SecurityConfiguration

package kr.co.jshpetclinicstudy.infra.config;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {

    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // ... 생략
        
        // Authorization (인가)
        http
                .authorizeHttpRequests()
                .requestMatchers("/api/v1/register", "/api/v1/login", "api/v1/refresh").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/members/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().permitAll();

        // ... 생략
    }

    // ... 생략
}

 

 


 

이제 정말 RefreshToken이 제대로 동작하는지 확인해보기 위해서 postman을 통해 확인해보겠다.

 

1. 회원가입

 

2. 로그인

 

3-1. Access, Refresh Token 정보로 재발급 요청

 

3-2. 1분 지나고, 만료된 Access Token 정보로 재발급 요청

 

3-3. 만료된 Refresh Token 정보로 재발급 요청 

 

 


 

 

728x90