지난번 JWT를 구현한 과정에 이어서 Refresh Token을 사용하여 더욱 강화된 JWT를 구현해보고자 한다.
만약 JWT에 대한 기본 구현 방법을 모른다면 해당 글을 읽기 이전에 이전에 작성한 글을 먼저 읽어보길 추천한다.
아래에 작성된 예제 코드도 위 링크의 코드를 기반으로 작성했음을 유의하자.
먼저 Refresh Token이 왜 필요한지를 먼저 알아보자
01. Refresh Token의 필요성과 장단점
기존 Access Token만을 사용한 인증 방식의 문제점
물론 지난번에 구현한 기초적인 jwt만으로도 간편하게 클라이언트의 요청에 다한 인가 작업을 처리할 수 있다.
하지만 jwt 방식에는 큰 문제가 하나 있다.
그것은 바로 jwt 토큰이 탈취되었을 경우 어떻게 대처할 것인가?에 관련된 문제이다.
다들 알다시피 서버측에서는 jwt 토큰을 발급한 후 서버에 토큰에 관한 어떠한 정보도 저장하지 않는다.
그렇기 때문에 한번 발급한 토큰에 관해서는 서버가 제어할 수단이 존재하지 않는다.
만약 세션 기반 인증방식에서, 클라이언트의 세션 아이디가 탈취되었다면, 서버측에서 해당 세션 아이디에 관련된 정보를 제거하여 탈취된 세션 아이디가 무용지물이 되도록 할 수 있다. 하지만 jwt 방식에서는 그것이 불가능하다.
그렇다면 이런 문제를 어떻게 해결할 수 있을까?
이를 위해서 Refresh Token이라는 전략이 등장했다.
Refresh Token이란?
Refresh Token
은 단어 그대로 토큰을 Refresh하는, 즉 토큰을 새로 만드는 토큰이다.
refresh token의 기본적인 전략은 아래와 같다.
- 클라이언트가 서버에 성공적으로 로그인하면 서버는
Access Token
과Refresh Token
을 발급함- 이때
Access Token
은 비교적 짧은 만료 시간을 가짐, 필자는 보통 5~10분정도로 설정 Refresh Token
은 그보다 긴 만료 시간을 가짐, 보통 12~24시간 정도로 구현하는 예제가 많음
- 이때
- 클라이언트는 이후 서버의 권한이 필요한 페이지에 접근할때,
Access Token
을 가지고 접근을 요청함- 서버는 기존의 jwt방식과 동일하게 토큰 검증 과정을 수행하고, 클라이언트에 응답
- !!!!이때 만약
Access Token
이 만료된 상태라면 서버는 클라이언트와 미리 협의된 응답을 던짐(http 상태코드, json 등)
- 만약 클라이언트가 서버를 요청하는 과정에서
Access Token
이 만료되어, 서버와 미리 협의된 응답이 돌아왔을 경우 에러 핸들러를 사용하여 처리- 에러 핸들러에서는
Refresh Token
과 함께, 토큰을 새로 발급받는 페이지로 요청을 전송, 서버는 해당Refresh Token
을 검증 후 새로운 토큰을 발급
- 에러 핸들러에서는
- 클라리언트는 새롭게 발급받은 토큰으로 다시 한번 서버에 페이지 접근 요청을 보낸다.
여기서 주의 깊게 볼 부분은 Access Token
의 만료 시간 부분이다.
서버가 토큰을 발급할때는 꼭 Access Token
의 만료 시간을 짧게 설정해야한다.
토큰의 주기를 짧게 하여, 해커가 토큰을 탈취하여도 해당 토큰이 곧 만료되어 쓸모가 없어지게 하는것이 Refresh Token 전략의 핵심이다.
그런데 여기서 한가지 의문이 들 수 있다.
만약 Refresh Token
이 탈취당하면, 어떻게 해야할까?Refresh Token
은 긴 만료 시간을 가지기 때문에, 만약 해당 토큰이 탈취된다면 위 과정이 무용 지물이 될 것이다.
이런 문제를 해결하기 위해 서버에서는 Refresh Token
의 정보를 저장 및 관리한다.
이렇게 한다면 만약 서버가 Refresh Token
을 탈취하여도 해당 토큰이 처리되지 않도록 서버에서 관리할 수 있다.
그럼 한번 코드로 구현한 예제를 봐보자.
02. 전체 작동 방식
우선 서론해서 말했듯 후술할 코드는 이전에 작성한 글의 코드를 기반으로 작성한다.
이전의 내용과 달라진 부분은 해당 포스트에서 설명하지 않겠다.
Refresh Token 구현의 전체적인 흐름은 위에서 설명한 과정과 동일하고 아래의 부분만 유의하면 된다.
Refresh Token
의 정보는MySQL
에 저장한다.- `아래 코드에서는 Refresh Token Rotation 전략을 사용하여 보안을 한 층 더 강화한다.
- RTR(Refresh Token Rotaion):
Refresh token
을 사용하여 토큰을 갱신한 후Refresh Token
도 함께 갱신한다.- 해당 방법을 통해 보안을 한층 높이고, 클라이언트의 로그인 지속 시간을 늘릴 수 있다.
- RTR(Refresh Token Rotaion):
- 서버는 토큰 정보를 응답할 때,
Refresh Token
은Cookie
로,Access Token
은Header
로 응답한다. - 마찬가지로 클라이언트도 서버에 요청할 때
Refresh Token
은Cookie
에,Access Token
은Header
에 포함시킨다.
그럼 하나씩 살펴보자
03. Entity, Repository 구현
RefreEntity
@Entity
@Getter
@Setter
public class RefreshEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
int id;
private String username;
private String refresh;
private String expiration;
}
Refresh Token을 서버 DB에 저장하기 위해 엔티티를 정의한다.
RefreshRepository
public interface RefreshRepository extends JpaRepository<RefreshEntity, Integer> {
Boolean existsByRefresh(String refresh);
@Transactional
void deleteByRefresh(String refresh);
}
마찬가지로 Repository도 정의한다.
04. JWTUtil
@Component
public class JWTUtil {
// private SecretKey secretKey;
private final SecretKey secretKey;
// 비밀키 값을 SecretKey 객체로 반환
public JWTUtil(@Value("${spring.jwt.key}") String key) {
this.secretKey = Keys.hmacShaKeyFor(key.getBytes());
}
// 토큰 생성
public String createJwt(String category, String username, String role, int expiredMinute){
// iat, exp를 위한 Date 및 Calendar
Calendar expCalendar = Calendar.getInstance();
expCalendar.add(Calendar.MINUTE, Math.toIntExact(expiredMinute));
Date iatDate = new Date();
Date expDate = expCalendar.getTime();
return Jwts.builder()
.claim("category", category)
.claim("username", username)
.claim("role", role)
.issuedAt(iatDate)
.expiration(expDate)
.signWith(secretKey)
.compact();
}
// 토큰 검증 - 카테고리
public String getCategory(String token){
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return jws.getPayload().get("category", String.class);
}
// 토큰 검증 - 아이디
public String getUsername(String token){
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return jws.getPayload().get("username", String.class);
}
// 토큰 검증 - role
public String getRole(String token){
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return jws.getPayload().get("role", String.class);
}
// 토큰 검증 - 토큰 유효기간 비교
public Boolean isExpired(String token){
try{
Jws<Claims> jws = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
Date expDate = jws.getPayload().getExpiration();
// 현재 날짜가 exp 날짜보다 뒤에 있으면, 만료됨
return new Date().after(expDate);
} catch (ExpiredJwtException e){
e.printStackTrace();
return true;
}
}
}
createJwt()
해당 토큰이Access Token
인지Refresh Token
인지 구별하기 위해category
클레임을 추가한다.getCategory()
: 해당 토큰의 카테고리를 식별한다.
나머지 부분은 이전과 동일하다.
05. CustomUsernamePasswordAuthentication
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
ObjectMapper objectMapper = new ObjectMapper();
AuthenticationManager authenticationManager;
JWTUtil jwtUtil;
RefreshRepository refreshRepository;
public CustomUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager
, JWTUtil jwtUtil, RefreshRepository refreshRepository) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
this.refreshRepository = refreshRepository;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// request body GET
ServletInputStream inputStream;
String requestBody;
try {
inputStream = request.getInputStream();
requestBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
// Json data parsing
LoginDto loginDto;
try {
loginDto = objectMapper.readValue(requestBody, LoginDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword(), null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// Refresh token 구현
String username = authResult.getName();
String role = authResult.getAuthorities().stream().findAny().get().getAuthority();
String accessToken = jwtUtil.createJwt("access", username, role, 1);
String refreshToken = jwtUtil.createJwt("refresh", username, role, 12*60);
addRefresh(username, refreshToken, 12*60);
response.setHeader("access", accessToken);
response.addCookie(createCookie("refresh", refreshToken));
response.setStatus(HttpStatus.OK.value());
}
protected void addRefresh(String username, String refresh, int expiredMinute){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expiredMinute);
Date date = calendar.getTime();
RefreshEntity refreshEntity = new RefreshEntity();
refreshEntity.setUsername(username);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
Cookie createCookie(String key, String value){
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(12*60*60); // 12h
cookie.setHttpOnly(true); //JS로 접근 불가, 탈취 위험 감소
return cookie;
}
}
successfulAuthentication()
: 만약 인증이 성공하면Access Token
과Refresh Token
을 발급 및 응답한다.- 이때 발급된
Refresh Token
은 서버의 DB에 저장한다. - 발급된
Access Token
은 헤더로,Refresh Token
은 쿠키로 응답한다.
- 이때 발급된
addRefresh()
: 발급된Refresh Toekn
을 서버에 저장createCookie()
:refresh toekn
응답을 위한 쿠키 객체를 생성
06. JwtVertificationFilter
public class JwtVerificationFilter extends OncePerRequestFilter {
ObjectMapper objectMapper = new ObjectMapper();
JWTUtil jwtUtil;
public JwtVerificationFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request body GET
String token = request.getHeader("Authentication");
// 토큰 존재 여부 검증
if (token == null){
filterChain.doFilter(request, response);
return;
}
token = token.split(" ")[1];
// 토큰 만료 검증
try {
jwtUtil.isExpired(token);
} catch (ExpiredJwtException e) {
response.getWriter().write("access token is expired");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String category = jwtUtil.getCategory(token);
// 카테고리 검사(access)
if (!category.equals("access")){
response.getWriter().write("invalid access token");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 임시 세션 추가
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
CustomUserDetails customUserDetails = new CustomUserDetails();
customUserDetails.setRole(role);
customUserDetails.setUsername(username);
// 세션 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
System.out.println("Success");
filterChain.doFilter(request, response);
}
}
- 토큰 만료 검증: 이전에는
jjwt
가 예외를 처리하도록, 만료 예외를JWTUtil
에게 넘겼다.- 하지만 해당 코드에서는 클라이언트와 미리 협의한 응답을 리턴한다.
- 토큰 카테고리 식별: 이후 토큰이
Access Token
일 경우에만 인가되도록 카테고리를 식별한다.
07. ReissueController
@RestController
public class ReissueController {
@Autowired
JWTUtil jwtUtil;
@Autowired
RefreshRepository refreshRepository;
@PostMapping("/reissue")
public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response){
// get refresh
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie:cookies){
if (cookie.getName().equals("refresh")) refresh = cookie.getValue();
}
// check if refresh is null
if (refresh == null) return new ResponseEntity("refresh token null", HttpStatus.BAD_REQUEST);
// expired check
if (jwtUtil.isExpired(refresh)) return new ResponseEntity("refresh token is expired", HttpStatus.BAD_REQUEST);
// check if category is refresh
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) return new ResponseEntity("invalid refresh token", HttpStatus.BAD_REQUEST);
// check if refresh is valid
if (!refreshRepository.existsByRefresh(refresh)) return new ResponseEntity("invalid refresh token", HttpStatus.BAD_REQUEST);
// crete new access token
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
String newAccess = jwtUtil.createJwt("access", username, role, 10);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 12*60);
// rotate refresh
refreshRepository.deleteByRefresh(refresh);
addRefresh(username, newRefresh, 12*60);
// return response
response.addHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity(HttpStatus.OK);
}
protected void addRefresh(String username, String refresh, int expiredMinute){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expiredMinute);
Date date = calendar.getTime();
RefreshEntity refreshEntity = new RefreshEntity();
refreshEntity.setUsername(username);
refreshEntity.setRefresh(refresh);
refreshEntity.setExpiration(date.toString());
refreshRepository.save(refreshEntity);
}
Cookie createCookie(String key, String value){
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(12*60*60); // 12h
cookie.setHttpOnly(true); //JS로 접근 불가, 탈취 위험 감소
return cookie;
}
}
이후 클라이언트가 토큰 재발급 요청을 할 수 있는 경로를 처리할 컨트롤러를 등록한다.
- 클라리언트가
Refresh Token
을 쿠키에 담아/reissue
경로로 요청할 경우,Refresh Token
을 검증 후 새로운 토큰을 발급한다. - 전체적인 토큰 검증 과정은 이전 과정과 유사하다.
- 해당 예제에서는
Rotaion
전략을 사용하므로, 이전 Refresh Token은 서버에서 지우고, 새로 발급된 토큰을 다시 저장한다.
08. SecurityConfig
// 경로별 인가 설정
httpSecurity.authorizeHttpRequests(auth -> {
auth.requestMatchers("/", "/login", "/join", "/signin", "/reissue").permitAll()
.requestMatchers("/onlyuser").hasRole("USER");
});
/reissue
경로에 아무나 접근할 수 있도록 설정한다.
09. 실행 결과
로그인
Access Token
과 Refresh Token
을 성공적으로 발급받은 것을 확인 가능
토큰 재발급
마찬가지로 Refresh Token
을 쿠키에 담아 요청했을 때, 동일하게 Access Token
과 Refresh Token
을 응답받음
Reference
'Back End > Spring && Spring Boot' 카테고리의 다른 글
[Spring boot] @Scheduled 스케줄러를 사용해서 Refresh 토큰 관리하기 (0) | 2024.08.10 |
---|---|
[Spring Boot] CORS 설정하기 (0) | 2024.08.05 |
[Spring Security / JWT] 스프링 부트에서 JWT 구현하기 with Spring Security (0) | 2024.07.10 |
[Spring Security] UsernamePasswordAuthenticationFilter 내부 동작 파해치기 (0) | 2024.07.05 |
[Spring boot / h2] Embedded VS In-Memory VS Server (0) | 2024.06.26 |