[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 정리
JWT를 이용한 로그인을 구현하는 예제를 해보다, AccessToken과 RefreshToken을 구분해서 사용하는 이유와 정확한 개념을 이해하고 싶어 찾아보게 되었다. 다들 refreshToken을 사용하는 이유는 더 높은 보
soohykeee.tistory.com
구현 과정
RefreshToken의 인증을 간단하게 살펴보면 다음과 같다.
- AccessToken을 검증하여 유저 확인
- DB에서 해당 유저 RefreshToken을 가져오기
- RefreshToken을 비교하여 검증
이렇게 DB에 RefreshToken을 저장하고 가져올 때, 만료시간을 관리해주기 위해서는 TTL (Time To Live)를 사용하는데, 지금 현재 우리가 사용하고 있는 RDBMS - MySQL에서는 어려움이 존재한다. 그렇기에 해당 RefreshToken을 관리해주기 위해서 많은 개발자들이 NoSQL을 사용해주고 있다. 그렇기에 해당 프로젝트에서는 Redis를 적용해 줄 것이다.
여기서 위의 RefreshToken의 정리에서 작성한 내용처럼 2가지의 방법이 존재한다.
- RefreshToken을 JWT로 발급하여 관리
- NoSQL의 TTL을 이용하여 RefreshToken을 일정 시간만큼 저장
해당 내용에 대해서는 위의 첨부한 글에 정리가 되어있다. 여기서 해당 프로젝트에서는 2번째 방법으로 UUID를 이용하여 랜덤한 키 값을 통해 RefreshToken을 생성하고 이를 NoSQL에서 관리하는 방식으로 해줄것이다.
우선 위의 글들을 참고하여 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에 대한 정보를 가져온다.
- redisConnectionFactory() : Redis 연결 팩토리를 생성하는 빈(Bean) 메서드이다. LettuceConnectionFactory를 사용하여 Redis 호스트와 포트로 연결을 설정한다. 생성된 Redis 연결 팩토리는 Spring의 RedisTemplate에서 사용된다.
- 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에 저장될 데이터 모델을 나타내는 클래스이다.
- @RedisHash("refreshToken"): Redis에 저장될 데이터의 해시(Hash) 이름을 "refreshToken"으로 설정
- id 필드를 해당 데이터의 식별자로 지정한다. 해당 필드는 Redis에 저장되지만, JSON 직렬화 시에는 @JsonIgnore 어노테이션에 의해 무시가 된다.
- refreshToken: Redis에 저장될 refreshToken
- @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 정보로 재발급 요청