[JWT 인증] AccessToken만으로 충분할까?
Access Token과 Refresh Token의 동작 방식
1. 클라이언트 인증 (로그인 시):
사용자가 로그인을 시도하면, 서버는 사용자의 인증 정보를 확인하고 Access Token과 Refresh Token을 발급합니다.
Access Token: 짧은 유효 기간을 가지며, 클라이언트가 서버의 보호된 리소스에 접근할 때 사용됩니다.
Refresh Token: 더 긴 유효 기간을 가지며, Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용됩니다.
2. Access Token을 이용한 인증:
클라이언트는 서버에 요청을 보낼 때마다 Access Token을 포함하여 인증을 진행합니다.
서버는 각 요청에서 Access Token을 검증하여, 유효하면 클라이언트의 요청을 처리하고, 만료되었으면 클라이언트에게 만료되었음을 알립니다.
3. Access Token 만료 시 Refresh Token을 통한 재발급:
Access Token이 만료되면 클라이언트는 Refresh Token을 이용해 서버에 새로운 Access Token 발급을 요청합니다.
서버는 Refresh Token을 검증한 후 유효하다면 새로운 Access Token을 발급하여 클라이언트에 전달하고, 클라이언트는 이 Access Token을 사용하여 계속해서 인증을 진행할 수 있습니다.
4. Refresh Token의 만료 및 재로그인:
Refresh Token도 유효 기간이 있기 때문에, 이마저 만료되었다면 클라이언트는 다시 로그인하여 새로운 Access Token과 Refresh Token을 발급받아야 합니다.
이 과정은 사용자의 인증을 지속적으로 유지하면서도 보안을 강화하는 데 기여합니다.
Access Token과 Refresh Token의 장단점 비교
구분 | Access Token | Refresh Token |
유효 기간 | 짧음 | 김 (몇 주에서 몇 개월까지 설정 가능) |
사용 목적 | 매 요청 시 사용자 인증 | Access Token이 만료되었을 때 새로운 Access Token을 발급 |
보안 위협 | 유출 시 짧은 시간 동안만 피해 발생 가능 | 유출 시 재발급 과정에서 추가적인 보안 위험 관리 필요 |
서버 저장 여부 | 보통 클라이언트에 저장 | 보안 강화를 위해 서버 또는 안전한 장소에 저장 |
사용성 | 자주 갱신이 필요 | 유효 기간이 길어 잦은 재로그인을 피할 수 있음 |
Access Token과 Refresh Token을 함께 사용하는 방식은 보안과 사용자 경험을 모두 고려한 최적의 설계로, 다음과 같은 이유로 권장됩니다.
Access Token과 Refresh Token의 조합을 통한 이점
보안 강화:
Access Token은 만료 시간이 짧기 때문에, 유출되어도 짧은 시간 내에 무효화됩니다.
Refresh Token은 서버에 안전하게 저장되며, 보통 클라이언트가 직접 접근할 수 없는 HttpOnly 속성의 쿠키로 관리되어 보안성이 높습니다.
사용자 경험 개선:
Access Token의 짧은 유효 기간에도 불구하고, Refresh Token을 통해 재로그인 없이도 새로운 Access Token을 발급받을 수 있으므로 사용자가 끊김 없이 서비스를 이용할 수 있습니다.
유출 사고 관리:
만약 Refresh Token이 유출되면 서버는 즉시 해당 토큰을 무효화하여, 유출된 토큰이 악용되지 않도록 조치할 수 있습니다.
일부 시스템에서는 Refresh Token을 주기적으로 갱신하거나, 로그인할 때마다 새로운 Refresh Token을 발급하여 관리하기도 합니다.
JWT 인증 시스템 코드 구현 예시
1. JwtTokenProvider 클래스: 토큰 생성 및 검증
JwtTokenProvider 클래스는 Access Token과 Refresh Token을 생성하고, 토큰의 유효성을 검증하는 역할을 합니다. Refresh Token은 보통 더 긴 유효 기간을 가지며, 서버에서 관리됩니다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${JWT_SECRET}")
private String secretKey;
@Value("${REFRESH_SECRET}")
private String refreshKey;
// Access Token 유효 기간 (예: 1시간)
private final Long accessTokenValidTime = 1000L * 60 * 60;
// Refresh Token 유효 기간 (예: 2주)
private final Long refreshTokenValidTime = 1000L * 60 * 60 * 24 * 14;
@PostConstruct
protected void init() {
// Secret Key와 Refresh Key를 각각 Base64로 인코딩하여 설정
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
refreshKey = Base64.getEncoder().encodeToString(refreshKey.getBytes());
}
// Access Token 생성 메서드
public String createAccessToken(String email, List<String> roles) {
return createToken(email, roles, accessTokenValidTime, secretKey);
}
// Refresh Token 생성 메서드
public String createRefreshToken(String email) {
return createToken(email, null, refreshTokenValidTime, refreshKey);
}
// 공통 토큰 생성 메서드
private String createToken(String email, List<String> roles, Long tokenValidTime, String key) {
Claims claims = Jwts.claims().setSubject(email);
if (roles != null) {
claims.put("roles", roles);
}
Date now = new Date();
// 토큰 생성 후 서명
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, key)
.compact();
}
// Access Token 또는 Refresh Token의 유효성 검증
public boolean validateToken(String token, String key) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date()); // 만료 여부 확인
} catch (Exception e) {
return false; // 유효하지 않은 토큰인 경우
}
}
// AccessToken으로 사용자 식별
public String getUsername(String token){
logger.info("[getUsername] 회원 구별 조회 시작");
String info = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody() // 토큰 본문
.getSubject(); // 토큰에서 Subject -> 회원 구별
logger.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
// RefreshToken으로 사용자 식별
public String getUsernameFromToken(String token) {
return Jwts.parser()
.setSigningKey(refreshToken)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
Access Token과 Refresh Token의 Secret Key를 분리한 이유
- 보안 강화
- Access Token은 짧은 유효 기간을 가지며, 클라이언트가 서버에 매 요청마다 전송하는 토큰입니다. 유출될 가능성이 있기 때문에, 이를 보완하기 위해 별도의 Secret Key를 사용합니다.
- Refresh Token은 주로 토큰 재발급 시에만 사용되고 서버에 저장되므로, Access Token과는 다른 Secret Key로 서명하여 각각의 유출 위험을 분리할 수 있습니다
- 역할 분리
- Access Token: 클라이언트가 보호된 리소스에 접근할 때 매번 서버에 전송하여 인증하는 데 사용됩니다.
- Refresh Token: Access Token이 만료되었을 때만 새로운 Access Token을 발급하는 데 사용되며, 서버에서만 관리됩니다. 다른 Secret Key를 통해 무결성을 보장할 수 있습니다.
- 유출 사고 시 대응 가능성
- Access Token의 Secret Key가 유출되더라도, Refresh Token은 별도의 Secret Key로 서명되어 있기 때문에 Refresh Token을 통한 인증에는 영향을 주지 않게 됩니다.
- 각 토큰을 독립적으로 관리할 수 있어 사고 발생 시 피해를 최소화할 수 있습니다.
Access Token과 Refresh Token을 통한 사용자 식별
기존에는 Access Token을 통해 매 요청마다 사용자를 식별했습니다. 이 과정에서 getUsername 메서드를 사용하여 Access Token에서 사용자 이메일(또는 ID)을 추출했는데, 이는 Access Token이 유효할 때만 사용 가능합니다. Access Token이 만료된 경우에는 더 이상 사용자 정보를 추출할 수 없으므로, 다른 방법으로 사용자를 식별해야 하는 상황이 발생합니다.
JWT 인증 구조에서는 이 문제를 해결하기 위해 Refresh Token을 사용하여 사용자 식별이 가능합니다. Refresh Token은 상대적으로 긴 유효 기간을 가지며, Access Token이 만료된 경우 새로운 Access Token을 발급할 수 있도록 서버에서 관리됩니다. 따라서, Refresh Token을 통해서도 사용자를 식별할 수 있어야 합니다.
Refresh Token으로 사용자 식별이 가능한 이유
JWT 토큰의 구조는 Access Token과 Refresh Token 모두 subject 클레임에 사용자 식별 정보를 포함하고 있습니다. 따라서 Refresh Token을 이용해도 JWT 토큰 파싱을 통해 subject 필드에 저장된 사용자 이메일 또는 ID를 추출할 수 있습니다.
아래의 getUsernameFromRefreshToken 메서드를 통해 Refresh Token에서 사용자 식별 정보를 추출할 수 있습니다.
public String getUsernameFromRefreshToken(String refreshToken) {
try {
// Refresh Token에서 사용자 이메일 추출 (JWT의 subject 필드 사용)
return Jwts.parser()
.setSigningKey(refreshKey) // Refresh Token에 사용되는 암호화 키
.parseClaimsJws(refreshToken)
.getBody()
.getSubject(); // subject 필드에서 사용자 이메일(ID) 추출
} catch (Exception e) {
// Refresh Token이 유효하지 않을 경우 예외 처리
throw new IllegalArgumentException("Invalid Refresh Token", e);
}
}
이 메서드를 통해 Refresh Token에서도 사용자 이메일(또는 ID)을 추출할 수 있으므로, Access Token이 만료되었더라도 유효한 Refresh Token을 통해 사용자를 식별하고 새로운 Access Token을 발급할 수 있습니다.
2. AuthService 클래스: Access Token과 Refresh Token을 통한 인증
AuthService 클래스는 Access Token이 만료된 경우 Refresh Token을 사용해 새로운 Access Token을 발급하는 로직을 구현합니다.
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
// Refresh Token을 사용해 Access Token을 갱신하는 메서드
public JwtResponse refreshAccessToken(String refreshToken) {
// Refresh Token의 유효성 검사
if (!jwtTokenProvider.validateRefreshToken(refreshToken)) {
throw new InvalidCredentialsException("Invalid or expired refresh token");
}
// Refresh Token에서 사용자 이메일 추출
String email = jwtTokenProvider.getUsernameFromRefreshToken(refreshToken);
User user = userRepository.findByEmail(email);
// DB에 저장된 Refresh Token과 요청된 Refresh Token이 일치하는지 확인
if (user == null || !refreshToken.equals(user.getRefreshToken())) {
throw new InvalidCredentialsException("Invalid refresh token");
}
// 새로운 Access Token 발급
String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getRoles());
// 필요 시 Refresh Token도 갱신 (유효 기간이 임박한 경우 등)
String newRefreshToken = jwtTokenProvider.refreshIfExpired(user.getEmail(), user.getRefreshToken());
// 새로운 Access Token과, 필요 시 갱신된 Refresh Token 반환
return new JwtResponse(true, HttpStatus.OK.value(), "Token refreshed successfully", newAccessToken, newRefreshToken);
}
}
3. AuthController 클래스: Refresh Token을 통한 Access Token 갱신 엔드포인트
클라이언트가 Access Token이 만료되었을 때 Refresh Token을 사용해 새로운 Access Token을 요청할 수 있는 엔드포인트를 구현합니다.
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
// Refresh Token을 통한 Access Token 갱신 요청
@PostMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(@RequestBody RefreshTokenRequestDto requestDto) {
try {
// Refresh Token을 사용하여 새로운 Access Token 발급
JwtResponse jwtResponse = authService.refreshAccessToken(requestDto.getRefreshToken());
return ResponseEntity.ok(jwtResponse);
} catch (InvalidCredentialsException e) {
// 유효하지 않은 Refresh Token일 경우 401 반환
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token");
} catch (Exception e) {
// 기타 오류 발생 시 500 반환
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to refresh token");
}
}
}
요약
- JwtTokenProvider는 Access Token과 Refresh Token을 생성하고 검증하는 역할을 합니다.
- AuthService는 Access Token이 만료되었을 때 Refresh Token을 사용해 새로운 Access Token을 발급합니다.
- AuthController는 클라이언트가 Access Token을 갱신할 수 있는 엔드포인트를 제공합니다.
느낀점
이번 프로젝트에서는 기존에 Access Token만을 사용하여 인증 시스템을 구현했었는데, 토큰이 만료될 때마다 사용자가 재로그인을 해야 하는 불편함이 있었습니다. 이 문제를 해결하기 위해 Refresh Token에 대해 조사하고 직접 구현해 보았습니다. 처음에는 막연히 어렵게 느껴졌지만, 차근차근 구조를 이해하고 코드를 작성해 가면서 생각보다 흥미롭게 작업할 수 있었습니다. 특히 Access Token이 만료가 되어서 Refresh Token을 통해 AccessToken을 재발급 해야하는데 사용자를 어떻게 Access Token없이 사용자를 식별할까?라는 생각을 하게 되었습니다. 결국 Refresh Token을 통해 가능하다는 것을 알게 되었고 아주 당연한 얘기지만 정말 신기했습니다!!! Refresh Token을 활용한 인증 시스템을 통해 사용자 경험을 크게 개선할 수 있다는 점에서 의미 있는 경험이었습니다~~