Refresh Token 도입하기

qpwoeiru·2024년 4월 14일
0
post-thumbnail

Spring Security와 JWT를 학습했으므로 refresh token을 도입해봤다.


Refresh Token?

refresh token은 access token을 재발급할 때 사용되는 토큰으로, access token보다 유효 기간이 훨씬 더 긴 토큰이다.

Refresh Token 사용 이유

access token은 발급 되면, 서버에 저장되지 않은 채로 사용자 권한을 인증한다. (Stateless)
Stateless의 특성 때문에 access token이 탈취 된다면 토큰을 갖는 누구나 권한 인증이 가능하다. 이를 해결하기 위해 access token의 만료 기간을 짧게 주는 것인데, 만료 기간이 짧은 만큼 사용자는 만료될 때마다 다시 로그인하고 토큰을 발급받아야 하는 번거로움이 발생한다.
해당 번거로움을 줄이고자 access token보다는 만료 기간이 긴 refresh token을 사용해 주기적으로 access token을 재발급받을 수 있도록 만드는 것이다.

Refresh Token 프로세스

  1. 로그인 시, 서버는 access token과 refresh token을 발급해 클라이언트측으로 전달
  2. 클라이언트는 받은 access token, refresh token을 로컬에 저장
  3. API 통신시에는 헤더에 access token을 넣고 요청함
  4. access token이 만료됐을 경우, 서버 측에서는 권한이 없다는 응답을 보냄
  5. 클라이언트는 refresh token을 넣어 토큰을 재발급하는 API를 호출
  6. 서버에서 refresh token이 유효한지 확인한 후 새로운 access token을 발급해 클라이언트에 전달
  7. 재발급을 요청하며 넘긴 refresh token도 만료 됐다면 클라이언트는 재로그인 필요

Refresh Token 문제점

access token의 탈취 가능성 위험을 줄이기 위해 refresh token을 도입했지만, 탈취가 완전히 방지되는 것이 아니다. 만료 기간이 길어 토큰의 탈취 위험이 좀 더 낮아질 뿐, Stateless 상태로 여전히 탈취의 가능성은 존재 한다.


Refresh Token 도입하기

MySQL을 사용하므로, refresh token은 user 정보와 함께 테이블로 저장했다. refresh_token 테이블의 형태는 아래와 같다.refresh token에 매핑되는 user와 refresh token의 만료 기간을 갖고 있어야 한다.

@Getter
@Entity
@NoArgsConstructor
public class RefreshToken {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "user_id")
	private User user;
	private String refreshToken;
	private Date validity;

	@Builder
	public RefreshToken(User user, String refreshToken, Date validity) {
		this.user = user;
		this.refreshToken = refreshToken;
		this.validity = validity;
	}
}

refresh token으로 access token을 재발급 하는 상세 로직은 다음과 같다.

  1. 로그인 시, access token과 refresh token 발급
  2. access token이 만료됐을 경우, 클라이언트 측에서 만료된 jwt를 감지하면 access token 재발급 요청 API 호출
    • 이 때 refresh token 및 만료된 access token 둘 다 파라미터로 담아 보냄
  3. 서버로 토큰 재발급 요청 들어오면 refresh token 유효성을 검사
    a. refresh token이 유효한 경우
    -> access token 재발급 후 토큰 리턴 (HttpStatus.OK)
    b. refresh token이 만료된 경우 (유효하지 않은 경우)
    -> 로그인을 다시 하도록 요청 (HttpStatus.UNAUTHORIZED) 및 refresh_token 테이블에서 해당 record 삭제

access token 재발급을 요청할 때, refresh token과 만료된 access token도 파라미터로 넘기는 이유는 현재 토큰 재발급을 요청한 사람이 사용자 본인에 대해 재발급을 요청한 것이 맞는지 서버측에서 확인하기 위해서이다.
( 타인의 토큰을 재발급 하는 것을 방지하기 위함 )

1. 로그인 시 response에 refresh token 추가하기

refresh token을 도입하기 전까지는 로그인 시 access token만 response의 body에 담아 전달했었다. 이제는 로그인 시 refresh token도 추가로 보내줘야 하기에 로그인 response DTO인 JwtDTO에 refreshToken을 추가한다.

@Builder
public record JwtDTO(
	String grantType,
	String accessToken,
	String refreshToken
){}

UserService

먼저 UserService에서 로그인 시 Authentication 정보를 TokenProvider의 createTokens()에 넘겼다.

...
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder managerBuilder;

public JwtDTO signIn(SignInRequest request) {
	UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
			request.email(), request.password());

	Authentication authentication = managerBuilder.getObject().authenticate(authenticationToken);

	return tokenProvider.createTokens(authentication);
}
...

TokenProvider

TokenProvider의 createTokens()는 인자로 넘어온 Authentication 정보를 사용해서 access token과 refresh token을 발급해 넘긴다.

public JwtDTO createTokens(Authentication authentication) {
	String accessToken = createAccessToken(authentication);
	String refreshToken = createRefreshToken(accessToken);

	log.info("success to create tokens");
	return JwtDTO.builder()
    		.grantType("Bearer")
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
}

TokenProvider의 createAccessToken()는 인자로 넘어온 Authentication 정보로 access token을 발급하는 메서드로 아래와 같다.

private String createAccessToken(Authentication authentication){
	// 인증된 사용자에게서 권한 목록 가져오기
	String authorities = authentication.getAuthorities() 
		.stream()
		.map(GrantedAuthority::getAuthority)
		.collect(Collectors.joining(","));

	// this.accessTokenValidityInMilliseconds : 미리 설정해 둔 access token 유효 기간 값
	Date accessValidity = new Date(new Date().getTime() + this.accessTokenValidityInMilliseconds);
    
    // JWT access token 생성
	return Jwts.builder()
		.setSubject(authentication.getName()) // 토큰의 subject(주체)를 인증된 사용자 이름으로 설정 
		.claim(AUTHORITIES_KEY, authorities) // 토큰 권한 정보를 클레임에 추가
		.signWith(this.key, SignatureAlgorithm.HS256) // 비밀키, 암호화 알고리즘 설정
		.setExpiration(accessValidity) // 만료 기간 설정
		.compact(); // builder에서 최종적으로 JWT string 생성
}

TokenProvider의 createRefreshToken()은 refresh token을 생성한다. 이 때 access token을 인자로 받는데, 토큰의 주체(subject)를 사용해 해당 사용자의 user 객체를 가져오고, 생성한 refresh token와 매핑해서 DB에 담기 위해 사용된다.
DetailsService의 loadUserByUsername()는 인자로 넘어오는 email 값으로 UserRepository에서 해당하는 유저를 반환하는 메서드이다.

private String createRefreshToken(String accessToken){
	Claims claims = Jwts.parserBuilder()
    					.setSigningKey(key) // key : 서버에서 설정한 비밀키
                        .build()
                        .parseClaimsJws(accessToken) // access token의 클레임 파싱
                        .getBody();
    
    // 토큰의 subject가 user의 Email로 저장되므로 loadUserByUsername()의 인자로 넘기기
	SecurityUser user = (SecurityUser) detailsService.loadUserByUsername(claims.getSubject());
    // 해당 유저에 대해 refresh token 조회
	Optional<RefreshToken> refreshTokenByUser = refreshTokenRepository.findByUser(user.getUser());
	
    // refresh token이 이미 존재하는 경우
	if(refreshTokenByUser.isPresent())
		return refreshTokenByUser.get().getRefreshToken(); // 해당 refresh token 반환

	// refresh token이 존재하지 않으므로 refresh token 생성
	Date refreshValidity = new Date(new Date().getTime() + this.refreshTokenValidityInMilliseconds); // refreshToken 만료 기간 설정
	String refreshToken = Jwts.builder()
		.signWith(this.key, SignatureAlgorithm.HS256) // 비밀키, 암호화 알고리즘 설정 
		.setExpiration(refreshValidity) // 만료 기간 설정
		.compact(); // 최종 JWT string 생성

	// RefreshTokenRepository에 user와 매핑해 refresh token 저장 
	refreshTokenRepository.save(new RefreshToken(user.getUser(), refreshToken, refreshValidity));

	return refreshToken;
}

로그인 했을 때 해당 유저에 대한 refresh token이 이미 존재하는 경우, 기존에 있던 refresh token을 반환하기

  • 기존에 내가 계획했던 로그인 로직에서는 해당 유저에 대해 유효한 refresh token이 이미 존재하면, 이미 로그인이 되어있음을 의미하므로 무조건 access token으로 API 요청을 해야한다고 생각했었다.
  • 그래서 user와 refresh token 간 매핑도 OneToOne 관계로 두고, 로그인 시에는 예외 없이 해당 사용자에 대해 refresh token을 생성하는 방식으로 진행했다.
  • 그런데 만약 사용자가 동시 접속을 하고자 한다면, refresh token이 만료되지 않았어도 로그인을 통해 refresh token 발급을 한 번 더 요청될 수 있을 것이라는 피드백을 받았다.
  • 현재 refresh token과 user는 OneToOne 매핑이므로 이미 존재하는 user에 대해 refresh token 발급을 한 번 더 요청 할 시, error가 발생하게 된다.
  • 위의 피드백을 받아 refresh token이 이미 존재하는 경우에는 새로 refresh token을 발급하지 않고, 기존에 있던 refresh token을 넘기는 것으로 변경했다.

2. access token 만료 시, refresh token으로 재발급 요청하기

UserController

refresh token으로 access token 재발급을 요청하는 API를 추가한다. access token의 주인과 refresh token의 주인이 같은지 확인하기 위해 둘 다 파라미터로 받는다.

@GetMapping("/refresh")
@Operation(summary = "access 토큰 재발급 요청 API")
public ResponseEntity<BaseResponse<String>> refreshToken(@RequestParam String accessToken, @RequestParam String refreshToken){
	String result = userService.requestNewAccessToken(accessToken, refreshToken);

	if(result.equals(HttpStatus.UNAUTHORIZED.toString())) // refresh token이 만료된 경우
		return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new BaseResponse<>(false, HttpStatus.UNAUTHORIZED, "만료된 refresh token", "재로그인을 요청해주세요."));
	else// refresh token 사용해서 정상 재발급 된 경우
		return ResponseEntity.ok(new BaseResponse<>(true, HttpStatus.OK, "Access token 재발급 성공", result));
}

UserService

TokenProvider의 validateRefreshToken()으로 만료된 access token과 refresh token을 넘긴다.

public String requestNewAccessToken(String accessToken, String refreshToken){
	return tokenProvider.validateRefreshToken(accessToken, refreshToken);
}

TokenProvider

validateRefreshToken() 메서드는 인자로 넘어온 access token의 주체(subject)와 refresh token의 user가 동일한지 확인한다. 동일하지 않다면 UnauthorizedAccessException을 던진다.
refresh token이 유효한 경우 (만료 기간이 지나지 않은 경우), 새로운 access token을 발급한다.
refresh token이 유효하지 않은 경우 (만료 기간이 지난 경우), 재로그인 하도록 요청한다.

public String validateRefreshToken(String accessToken, String token){ 
		RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(token)
			.orElseThrow(() -> new UnauthorizedAccessException("존재하지 않는 refresh token"));
		String subject;
		
        // 만료된 access token에서 주체(subject) 추출을 위한 클레임 파싱 
		try {
			Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
			subject = claims.getSubject();
		} catch (ExpiredJwtException e){
			subject = e.getClaims().getSubject();
		}
		
		 // 만료된 accessToken의 주인과 입력한 refresh token의 유저가 같은지 판단
 		if(!subject.equals(refreshToken.getUser().getEmail()))
			throw new UnauthorizedAccessException("만료된 access token의 user와 요청한 refresh token의 user가 동일하지 않습니다.");

		// refresh token이 유효하지 않은 경우 (만료기간이 지난 경우) => 로그인 풀도록 요청
		if(refreshToken.getValidity().before(new Date())) {
			// RefreshToken DB에서 해당 엔티티 삭제 후 만료 response 반환
			refreshTokenRepository.delete(refreshToken);
			return HttpStatus.UNAUTHORIZED.toString();
		}
		// refresh token이 유효한 경우 => access token 발급
		else{
			UserDetails userDetails = detailsService.loadUserByUsername(refreshToken.getUser().getEmail());
			Authentication authentication = new UsernamePasswordAuthenticationToken(
				userDetails.getUsername(), null, userDetails.getAuthorities());
			return createAccessToken(authentication);
		}
	}

이건 개인적인 생각이긴 한데, 리프레시 토큰을 JWT로 생성하게 되면 결국 일반 액세스 토큰과 똑같은 문제가 발생한다는 점에서 이 방법이 과연 최선일까 싶었다. 리프레시 토큰 생성 방법, 그리고 이를 DB에 저장해도 되는지 여부에 대해선 더 찾아보고 깊은 고민이 필요할 것 같다.

0개의 댓글

관련 채용 정보