오늘은 이전에 포스팅한 jwt 적용기의 2편이다.
Access Token을 적용하고 아주 큰? 문제를 발견했다.
보안 상으로 Access Token은 매우 짧은 만료기간을 가지고 있다. 그래서 사용자는 매번 만료가될 시 로그인을 새로 하여, 새롭게 Access Token을 받아야 한다는 것이다.
또한
사용자의 자동로그인에 문제가 생겼다.
여러가지의 해결방법이 있다.
그럼 Refresh Token에 대해서 알아보자.
간단하게, Access Token을 재발급 받기위한 Token이다.
OAuth2.0을 이용하여 타서비스 로그인 기능을 구현한 경험이 있다면, 누구나 들어보았을 것이다.
flow는 다음과 같이 움직인다.
클라이언트에서 로그인한다.
서버는 클라이언트에게 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장된다.
클라이언트는 local 저장소에 두 Token을 저장한다.
매 요청마다 Access Token을 헤더에 담아서 요청한다.
이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.
클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.
서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.
클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 된다.
7번 과정에서 Refresh Token도 갱신하는 경우가 있다. 하지만 나는 갱신을 하지 않았다.
보통의 경우
Access Token 만료기간 : 30분 ~ 1시간
Refresh Token 만료기간 : 3일 ~ 1달으로 설정하는 듯 하다.
나는 Access Token은 30분, Refresh Token은 14일로 지정할 예정이다.
구현하면서 임시로 log와 어노테이션을 생성하여 조금 지저분한 감이 있다..
가장 먼저, 토큰을 발급할 때 Access Token과 Refresh Token을 같이 발급하도록 하였다.
코드는 아래를 보자.
public Token createAccessToken(String userEmail, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userEmail); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
//Access Token
String accessToken = Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, accessSecretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
//Refresh Token
String refreshToken = Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + refreshTokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, refreshSecretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
return Token.builder().accessToken(accessToken).refreshToken(refreshToken).key(userEmail).build();
}
코드를 살펴보면 token을 2개 생성한 모습을 볼 수 있다.
또한 Token은 TokenDTO의 역할로 만든 객체이다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Token {
private String grantType;
private String accessToken;
private String refreshToken;
private String key;
}
여기까지 진행되면, 2개의 토큰(Access, Refresh)가 생성되어 TokenDTO 객체로 반환하는 것을 볼 수 있다.
Refresh Token이 넘어왔을 때, 유효성 검증을 하는 메소드이다.
Access Token과 별개로 나둔 이유는 DB를 한번 거쳐야한다. 그래서 생성했다. (Access Token은 스프링 시큐리티 필터링 단계에서 해당 로직을 타기 때문에 별개로 나둬야 DB 접근 후 Refresh Token 객체에 맞는 유효성 검증 메소드를 탈 수 있다.)
public String validateRefreshToken(RefreshToken refreshTokenObj){
// refresh 객체에서 refreshToken 추출
String refreshToken = refreshTokenObj.getRefreshToken();
try {
// 검증
Jws<Claims> claims = Jwts.parser().setSigningKey(refreshSecretKey).parseClaimsJws(refreshToken);
//refresh 토큰의 만료시간이 지나지 않았을 경우, 새로운 access 토큰을 생성합니다.
if (!claims.getBody().getExpiration().before(new Date())) {
return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
}
}catch (Exception e) {
//refresh 토큰이 만료되었을 경우, 로그인이 필요합니다.
return null;
}
return null;
}
해당 메소드는 Refresh Token을 받아 유효성 검증을 하는 메소드이다.
유효성 검증을 통과하게 되면, 새로운 Access Token을 생성하고 반환한다.
public String recreationAccessToken(String userEmail, Object roles){
Claims claims = Jwts.claims().setSubject(userEmail); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
//Access Token
String accessToken = Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, accessSecretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
return accessToken;
}
로그인 부분만 살펴보자.
// 로그인
@PostMapping("/login")
public Token login(@RequestBody Map<String, String> user) {
log.info("user email = {}", user.get("userEmail"));
User member = userRepository.findByUserEmail(user.get("userEmail"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
Token tokenDto = jwtTokenProvider.createAccessToken(member.getUsername(), member.getRoles());
log.info("getroleeeee = {}", member.getRoles());
jwtService.login(tokenDto);
return tokenDto;
}
이전과는 다르게 TokenDTO 객체를 반환하는 것을 볼 수 있다.
Refresh 관련 로직을 따로 다루기 위해서 Controller를 새롭게 만들었다.
@Slf4j
@RestController
@RequiredArgsConstructor
public class RefreshController {
private final JwtService jwtService;
@PostMapping("/refresh")
public ResponseEntity<RefreshApiResponseMessage> validateRefreshToken(@RequestBody HashMap<String, String> bodyJson){
log.info("refresh controller 실행");
Map<String, String> map = jwtService.validateRefreshToken(bodyJson.get("refreshToken"));
if(map.get("status").equals("402")){
log.info("RefreshController - Refresh Token이 만료.");
RefreshApiResponseMessage refreshApiResponseMessage = new RefreshApiResponseMessage(map);
return new ResponseEntity<RefreshApiResponseMessage>(refreshApiResponseMessage, HttpStatus.UNAUTHORIZED);
}
log.info("RefreshController - Refresh Token이 유효.");
RefreshApiResponseMessage refreshApiResponseMessage = new RefreshApiResponseMessage(map);
return new ResponseEntity<RefreshApiResponseMessage>(refreshApiResponseMessage, HttpStatus.OK);
}
}
/refresh 요청이 들어오면 바디의 Refresh Token을 받아 유효성 검증을 하게 된다.
그리고 Response 메세지를 생성해서 반환한다.
사실 생성인지 수정인지 기억안난다. ㅋㅋ
그럼 이제 해당 컨트롤러로 요청이 오게 되었을 때, 로직을 수행해줄 Service가 필요하다.
@Service
@RequiredArgsConstructor
@Slf4j
public class JwtService {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public void login(Token tokenDto){
RefreshToken refreshToken = RefreshToken.builder().keyEmail(tokenDto.getKey()).refreshToken(tokenDto.getRefreshToken()).build();
String loginUserEmail = refreshToken.getKeyEmail();
if(refreshTokenRepository.existsByKeyEmail(loginUserEmail)){
log.info("기존의 존재하는 refresh 토큰 삭제");
refreshTokenRepository.deleteByKeyEmail(loginUserEmail);
}
refreshTokenRepository.save(refreshToken);
}
public Optional<RefreshToken> getRefreshToken(String refreshToken){
return refreshTokenRepository.findByRefreshToken(refreshToken);
}
public Map<String, String> validateRefreshToken(String refreshToken){
RefreshToken refreshToken1 = getRefreshToken(refreshToken).get();
String createdAccessToken = jwtTokenProvider.validateRefreshToken(refreshToken1);
return createRefreshJson(createdAccessToken);
}
public Map<String, String> createRefreshJson(String createdAccessToken){
Map<String, String> map = new HashMap<>();
if(createdAccessToken == null){
map.put("errortype", "Forbidden");
map.put("status", "402");
map.put("message", "Refresh 토큰이 만료되었습니다. 로그인이 필요합니다.");
return map;
}
//기존에 존재하는 accessToken 제거
map.put("status", "200");
map.put("message", "Refresh 토큰을 통한 Access Token 생성이 완료되었습니다.");
map.put("accessToken", createdAccessToken);
return map;
}
}
에러 처리는 해놓았다.
Refresh Token을 DB에 저장해서 관리해보자. 나는 JPA를 이용한다.
@Builder
@Entity
@Getter
@Table(name = "T_REFRESH_TOKEN")
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "REFRESH_TOKEN_ID", nullable = false)
private Long refreshTokenId;
@Column(name = "REFRESH_TOKEN", nullable = false)
private String refreshToken;
@Column(name = "KEY_EMAIL", nullable = false)
private String keyEmail;
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByRefreshToken(String refreshToken);
boolean existsByKeyEmail(String userEmail);
void deleteByKeyEmail(String userEmail);
}
이제 모든 준비가 완료되었다.
(참고로 해당 코드를 그대로 복사하면 당연히 돌아가지 않을 것이다. 커스텀한 메세지 클래스와 Exception들을 따로 설정한 것이 많다.)
로컬에 둘다 저장하면 액세스 토큰이 탈취되는 문제점을 리프레쉬 토큰이 그대로 가질텐데 토큰 둘다를 로컬에다 저장해도 되나요?