Spring Security에 JWT 적용하기 (2)
이 글에서는 앞서만든 JWT 방식에 Refresh Token을 적용하는 글입니다.
Refresh Token이란
Refresh Token은 Access Token과 같이 사용되며, Access Token보다 상대적으로 유효시간이 더 긴 토큰입니다.
주된 목적은 Access Token이 만료된 경우 새로운 액세스 토큰을 발급받기 위한 인증 수단으로 사용됩니다.
Refresh Token을 왜 적용할까
Refresh Token을 적용하기 전에 왜 Refresh Token을 적용하는지 알아두면 좋습니다.
보안 강화를 위한 짧은 유효 시간
Access Token은 stateless 방식으로 사용자쪽(브라우저나 앱)에 저장되어 API 요청시마다 사용되므로 탈취될 위험성이 있습니다.
이는 토큰을 탈취한 사람이 해당 유저의 모든 권한을 가지게 되므로 보안상의 위험이 있습니다.
그리고, 앞서 말했듯이 Access Token은 사용자쪽에 저장되므로 탈취된 토큰을 서버에서 파기할 수 없습니다.
위와 같은 이유로 Access Token의 유효시간을 짧게하여 탈취에 대한 문제를 그나마 해결합니다.
잦은 로그아웃
위에서 나왔듯이 Access Token은 보안상의 이유로 주로 15분 ~ 1시간 이내의 짧은 유효 시간이 주어집니다.
보안을 위한 Access Token의 짧은 유효시간은 사용자가 자주 로그인을 해줘야하는 부작용이 생깁니다.
사용자가 서비스를 사용하다가 15분 ~ 1시간이 지나면 Access Token이 만료되어 로그아웃처리되고, 다시 로그인을 해줘야하는 상황이 발생합니다.
이를 방지하기 위해서 유효시간이 상대적으로 긴 Refresh Token을 같이 발급해주어 Access Token이 만료되더라고
Refresh Token이 만료되지 않았다면 새로운 Access Token을 발급해주어 해당 문제를 해결할 수 있습니다.
만약 Refresh Token이 탈취당한다면 ?
Refresh Token도 API 요청시마다 사용되므로 당연하게 탈취될 위험성이 있습니다.
이 또한 서버측에서 탈취된 토큰을 파기할 수 없으므로 다른 안전장치(HttpOnly, Refresh Token Rotation)가 있습니다.
1. HttpOnly는 웹브라우저에서 사용되는 쿠키의 속성중 하나로 Javascript에서 해당 쿠키에 접근할 수 없도록 제한하여 XSS 공격을 방지해줍니다.
2. Refresh Token Rotation은 Refresh Token으로 Access Token을 재발급할 때 Refresh Token도 새로 만들어 발급하고 이전 토큰은 무효화하는 방식입니다.
즉, Refresh Token을 일회용으로 사용하게 바꿔 보안성을 높입니다.
하지만 RTR 방식은 이전 토큰의 무효화를 위해 서버에서 토큰에 상태관리가 필요하기 때문에 stateless의 장점은 포기해야합니다.
JWT 발급시 Refresh Token 추가
1. JWT 발급 클래스 변경
기존의 JwtProvider 클래스를 아래와 같이 바꿔줍니다.
@Component
public class JwtProvider {
private final SecretKey accessSecretKey;
private final SecretKey refreshSecretKey;
public JwtProvider(@Value("${jwt.access-secret}") String accessSecretKey, @Value("${jwt.refresh-secret}") String refreshSecretKey) {
// HS256 알고리즘을 사용하여 SecretKey를 초기화
this.accessSecretKey = new SecretKeySpec(accessSecretKey.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
this.refreshSecretKey = new SecretKeySpec(refreshSecretKey.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String createAccessJwt(String username, String role, Long expiredMs) {
// 사용자 이름, 역할 및 만료 시간(ms 단위)으로 JWT 생성
return Jwts.builder()
.claim("username", username) // claim으로 payload에 데이터를 넣어줄 수 있음.
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(accessSecretKey)
.compact();
}
public String createRefreshJwt(String username, Long expiredMs) {
// 사용자 이름, 만료 시간(ms 단위)으로 JWT 생성
return Jwts.builder()
.claim("username", username)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(refreshSecretKey)
.compact();
}
}
환경 변수에 Refresh Token을 생성할 secret key를 생성하고 jwt.refresh-secret으로 등록한 다음
생성자 주입 방식으로 추가해줍니다.
(하나의 비밀키만 사용하면 관리상으로는 편할 수 있습니다. 저도 이에 대해서 찾아보던 중 이 글을 읽고 동감하여 비밀키를 2개로 분리했습니다.)
그리고 Refresh Token을 생성해주는 메서드를 추가해줍니다.
(Refresh Token은 재발급을 위한 토큰이므로 유저의 접근권한인 role 필드는 제외해서 발급했습니다.)
2. 로그인 성공 시 Refresh Token도 같이 응답
이제 LoginFilter에서 인증 성공시 응답에 Refresh Token도 포함하도록 아래와 같이 변경해줍니다.
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
...
// 인증이 성공했을 때
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 인증된 사용자 정보를 호출
CustomUserDetails userDetails = (CustomUserDetails) authResult.getPrincipal();
// 사용자의 권한 정보 추출
Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String username = userDetails.getUsername();
// username, role로 1시간(60*60*1L) 동안 유효한 JWT 생성
String accessToken = jwtProvider.createAccessJwt(username, role, 60*60*1L);
// username으로 7일(60*60*24*7L) 동안 유효한 JWT 생성
String refreshToken = jwtProvider.createRefreshJwt(username, 60*60*24*7L);
// 응답 헤더에 Authorization으로 토큰 설정
response.addHeader("Authorization", "Bearer " + accessToken);
// 리프레시 토큰 쿠키에 추가
response.addCookie(createRefreshTokenCookie(refreshToken));
}
...
private Cookie createRefreshTokenCookie(String refreshToken) {
Cookie cookie = new Cookie("refresh_token", refreshToken);
cookie.setHttpOnly(true); // Javascript에서 접근할 수 없도록 설정
cookie.setSecure(true); // HTTPS에서만 전송되도록 설정
cookie.setMaxAge(60*60*24*7); // 쿠키의 유효기간 설정
cookie.setPath("/"); // 쿠키의 유효 범위 설정 (/은 모든 범위에서 쿠키가 전송되도록 설정)
return cookie;
}
}
Refresh Token을 Cookie에 추가해서 응답해줍니다.
Cookie를 만들 때 보안을 위해 HttpOnly, Secure 등의 설정을 해줍니다.
3. RefreshToken 확인
Refresh Token은 응답의 헤더에 Set-Cookie로 응답되어 브라우저의 쿠키 저장소에 자동으로 저장되게 됩니다.
JWT 재발급 추가
1. 재발급 로직 구현
Access Token 재발급을 위해서 AuthController를 만들어줍니다.
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/refresh")
public ResponseEntity<Void> refreshAccessToken(@RequestBody String refreshToken) {
String newAccessToken = authService.refreshAccessToken(refreshToken);
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + newAccessToken);
return ResponseEntity.ok().headers(headers).build();
}
}
그리고 AuthService 클래스를 만들어 Refresh Token을 검증하고 AccessToken을 만들어서 반환해주는 메서드를 추가해줍니다.
@Service
public class AuthServiceImpl implements AuthService {
private final JwtProvider jwtProvider;
private final JwtValidator jwtValidator;
private final UserRepository userRepository;
public AuthServiceImpl(JwtProvider jwtProvider, JwtValidator jwtValidator, UserRepository userRepository) {
this.jwtProvider = jwtProvider;
this.jwtValidator = jwtValidator;
this.userRepository = userRepository;
}
@Override
public String refreshAccessToken(String refreshToken) {
// Refresh Token 검증
if(!jwtValidator.isExpired(refreshToken)) {
// 토큰의 username 조회
String username = jwtValidator.getUsername(refreshToken);
// DB에서 role조회
UserEntity userEntity = userRepository.findByEmail(username)
.orElseThrow(() -> new TochookpiException(ErrorCode.EXPIRED_REFRESH_TOKEN));
String role = userEntity.getRole().name();
return jwtProvider.createAccessJwt(username, role, 60*60*1L);
}
throw new TochookpiException(ErrorCode.EXPIRED_REFRESH_TOKEN);
}
}
2. 흐름 정리
- 사용자가 보유한 Access Token으로 요청을 보냄
- 서버에서 Access Token 검증
- 검증 실패시 401 에러 리턴
- 클라이언트에서 에러를 체크해서 보유한 Refresh Token으로 새로운 Access Token 요청
- 서버에서 Refresh Token 검증
- 성공시 새로발급 받은 Access Token으로 기존 요청을 재요청
- 실패시, 로그아웃처리해서 로그인 화면으로 이동
위와 같은 흐름으로 진행됩니다.