[Spring] JWT, OAuth2.0, Email - Redis로 구현
시작하기에 앞서
우리는 이전에 JWT와 이메일 인증을 구현하는데 있어 각각의 토큰들을 DB에 저장하였다.
JWT는 Member 엔티티에 RefreshToken이라는 속성을 만들고, 이메일 인증은 EmailAuth라는 엔티티를 만들었다.
물론 이러한 구현이 틀렸다는 부분은 아니지만 이러한 인증 토큰과 관련된 부분은 Redis를 많이 사용한다.
Redis란 무엇이고 왜 Redis를 사용하는지 간단하게 살펴보자.
Redis는 Remote Dictionary Server의 약자로서 Key-Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템이다. (NoSql이다.)
In-Memory 기반의 데이터 처리 및 저장을 제공하여 속도가 빠르지만 서버가 꺼지면 모든 데이터가 사라진다.
그렇기에 보조 데이터베이스로 사용된다.
그렇다면 무조건 RDBMS를 사용해야하는 것일까?
RDBMS는 실제 물리적 장치에 저장하기 때문에 데이터를 영속적으로 관리할 수 있지만, 입출력에 다소 시간이 걸리는 단점이 존재한다. 이러한 여러가지 부분에 있어서 인증 토큰에 대해서는 Redis를 사용한다.
실제로 Redis를 사용해보자.
Redis 설정
Redis를 설치하는 부분은 홈페이지에 들어가서 설치하면 된다.
설치 방법의 경우 구글링을 하면 잘 나와있기에 이 부분에 대해서는 생략하겠다.
Spring Boot와 Redis를 연동하기 위해서는 몇 가지 설정이 필요하다.
먼저, Redis를 설치하게되면 별다른 설정 없이 진행할 경우 Host는 localhost, Port는 6379일 것이다.
이 부분에 대해 yml에 설정해보자.
spring:
redis:
host: localhost
port: 6379
그리고 실제로 Redis를 사용하기 위해 의존성을 설정해주어야 한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
우리는 이제 실제로 Redis와 통신하기 위해 RestTemplate을 사용한다.
그리고 사용하기 위해 Redis Client를 설정해야하는데 Redis Client는 크게 Lettuce와 Jedis가 있으나 흔히 Lettuce가 사용된다고 한다.
이제 설정을 적용해보자.
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<Long, Object> redisTemplate() {
RedisTemplate<Long, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
yml으로부터 host와 port를 받아와 RedisConnectionFacotry를 통해 Lettuce로 Connection을 진행해준다.
그리고 Key와 Value에 대해 직렬화 역직렬화가 가능하도록 Serializer을 설정해준다.
이제 필요한 로직에서 RestTemplate에서 주입받아 사용하기만 하면 된다.
이제 실제 Redis를 활용하여 인증 토큰을 처리해보도록 하자.
Entity
이전에 계속적으로 다루었던 Member 객체를 바라보자.
이전에 RefreshToken을 다루기 위해 DB에 저장되어 있던 부분을 삭제하였다.
@Entity
@Getter
@Table(name = "MEMBERS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String email;
private String password;
private String provider;
private Boolean emailAuth;
@ElementCollection(fetch = FetchType.LAZY)
@Enumerated(EnumType.STRING)
private List<Role> roles = new ArrayList<>();
@Builder
public Member(String email, String password, String provider, List<Role> roles, Boolean emailAuth) {
this.email = email;
this.password = password;
this.provider = provider;
this.roles = Collections.singletonList(Role.ROLE_MEMBER);
this.emailAuth = emailAuth;
}
public void addRole(Role role) {
this.roles.add(role);
}
public void emailVerifiedSuccess() {
this.emailAuth = true;
}
}
그리고 이전에 존재했던 EmailAuth 엔티티도 삭제해버렸다.
그러면 이제 실제 어떤식으로 진행하는지 확인해보자.
Service
Key, Value
먼저, Key와 Value를 설정하는데 있어서 다음과 같은 규칙을 설정하였다.
- 회원가입시 이메일 인증 코드 : Key(EAuth_이메일), Value(UUID값)
- RefreshToken : Key(Refresh_이메일), Value(RefreshToken값)
이를 간단하게 적용하기 위해 Enum 클래스를 하나 설정해두었다.
@Getter
public enum RedisKey {
REGISTER("Register_"), EAUTH("EAuth_");
private String key;
RedisKey(String key) {
this.key = key;
}
}
RedisService
우리는 실제 Redis에 읽고 쓰는 작업을 진행해야한다.
그러기에 우리가 편히 읽고 쓸 수 있도록 Service를 구성했다.
@Service
@Transactional
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate redisTemplate;
public String getData(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
public void setDataWithExpiration(String key, String value, Long time) {
if (this.getData(key) != null)
this.deleteData(key);
Duration expireDuration = Duration.ofSeconds(time);
redisTemplate.opsForValue().set(key, value, expireDuration);
}
public void deleteData(String key) {
redisTemplate.delete(key);
}
}
- getData : Key를 통해 Value를 읽어들임 (redisTemplate.opsForValue().get() 이용)
- setDataWithExpriration : 만료기간이 있는 Key-Value 값을 생성하여 저장(redisTemplate.opsForValue().set() 이용)
- deleteData : Key를 통해 해당 Key-Value를 삭제
이제 실제로 적용해보자.
SignService
이전에 EmailAuth 엔티티를 통해 인증코드를 저장하고 추후에 다시 불러와 검증해야하는 작업이 사라지고 단순 UUID 토큰을 생성하여 Redis에 저장하였다.
추후에 이 값을 비교하여 진행하면 될 것이다.
@Transactional
public MemberRegisterResponseDto registerMember(MemberRegisterRequestDto requestDto) {
validateDuplicated(requestDto.getEmail());
String authToken = UUID.randomUUID().toString();
redisService.setDataWithExpiration(RedisKey.EAUTH.getKey()+requestDto.getEmail(), authToken, 60*5L);
Member member = memberRepository.save(
Member.builder()
.email(requestDto.getEmail())
.password(passwordEncoder.encode(requestDto.getPassword()))
.provider(null)
.emailAuth(false)
.build());
emailService.send(requestDto.getEmail(), authToken);
return MemberRegisterResponseDto.builder()
.id(member.getId())
.email(member.getEmail())
.build();
}
public void validateDuplicated(String email) {
if (memberRepository.findByEmail(email).isPresent())
throw new MemberEmailAlreadyExistsException();
}
이메일을 검증하는 부분이다.
이전에는 EmailAuth를 불러와 실제로 값을 비교해야 했으나 현재는 Redis에서 아이디를 Key값으로 설정했기 때문에 단순히 Redis의 값을 불러와 처리하면 될 것이다.
@Transactional
public void confirmEmail(EmailAuthRequestDto requestDto) {
if (redisService.getData(RedisKey.EAUTH.getKey()+requestDto.getEmail()) == null)
throw new EmailAuthTokenNotFountException();
Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(MemberNotFoundException::new);
redisService.deleteData(RedisKey.EAUTH.getKey()+requestDto.getEmail());
member.emailVerifiedSuccess();
}
로그인을 진행하는 부분이다.
이전에는 RefreshToken을 DB의 Member 엔티티에 저장해줘야했으나 그럴 필요가 없다.
단순히 Redis에 저장하고 추후에 꺼내쓰면 되는 것이다.
@Transactional
public MemberLoginResponseDto loginMember(MemberLoginRequestDto requestDto) {
Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(MemberNotFoundException::new);
if (!passwordEncoder.matches(requestDto.getPassword(), member.getPassword()))
throw new LoginFailureException();
if (!member.getEmailAuth())
throw new EmailNotAuthenticatedException();
String refreshToken = jwtTokenProvider.createRefreshToken();
redisService.setDataWithExpiration(RedisKey.REGISTER.getKey()+member.getEmail(), refreshToken, JwtTokenProvider.REFRESH_TOKEN_VALID_TIME);
return new MemberLoginResponseDto(member.getId(), jwtTokenProvider.createToken(requestDto.getEmail()), refreshToken);
}
그리고 마지막으로 RefreshToken 재발행 부분이다.
이전에는 DB에서 값을 꺼내와서 비교후 해줘야했으나 지금은 Redis에서 꺼내와서 단순 비교 후 다시 발행해주고 이전에 발급받았던 RefreshToken을 삭제하고 이를 다시 추가해주는 부분이다.
@Transactional
public TokenResponseDto reIssue(ReIssueRequestDto requestDto) {
String findRefreshToken = redisService.getData(RedisKey.REGISTER.getKey()+requestDto.getEmail());
if (findRefreshToken == null || !findRefreshToken.equals(requestDto.getRefreshToken()))
throw new InvalidRefreshTokenException();
Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(MemberNotFoundException::new);
String accessToken = jwtTokenProvider.createToken(member.getEmail());
String refreshToken = jwtTokenProvider.createRefreshToken();
redisService.setDataWithExpiration(RedisKey.REGISTER.getKey()+member.getEmail(), refreshToken, JwtTokenProvider.REFRESH_TOKEN_VALID_TIME);
return new TokenResponseDto(accessToken, refreshToken);
}
이전부터 계속적으로 다루었던 개념들이라 크게 어렵게 다가오지 않을 것이다.
이제 실제로 이 부분들에 대해서 테스트를 진행해보자.
테스트
먼저, 회원가입을 진행해보자.
회원가입이 성공적으로 완료되었다면, 이메일 인증링크가 잘 가는지 보자.
잘 도착한다.
과연 이메일 인증 없이 진행하면 어떻게 될까?
다음과 같이 에러 코드를 배출한다.
그렇다면 링크를 들어가고 로그인을 다시 진행해보자.
로그인이 잘 된 모습을 확인할 수 있다.
이제 이 부분을 활용해 RefreshToken을 재발행해보자.
재발행까지 잘 되는 모습을 확인할 수 있다.
마무리
지금 Spring Security에서 Form Login을 시작으로 JWT, OAuth2.0 등을 거쳐 Redis 적용까지 해보았다.
이 부분들은 백에서만 처리해야될 부분들이 아니라 프론트와의 상호작용을 통해 진행이 되어야한다.
이에 따라, 프론트를 완성하여 진행한 후의 추후 결과를 살펴보아야 할 것이다.
현재 부분에 대한 코드로 이해가 되지 않을 경우 Git을 참조하면 좋을 것 같다.
깃허브
SangHyunGil/SpringSecurityJWT-Local-OAuth2-EmailAuth-Redis (github.com)