
이전 포스팅에서 JWT의 보안 전략과 각각의 장단점에 대해서 알아보았습니다. 이번 포스팅에서는 AccessToken과 RefreshToken을 사용한 JWT를 실제 구현한 코드를 보면서 다뤄보고자 합니다. Token의 생성과 재발급 및 삭제 관련 내용을 위주로 다뤄 보고자 합니다. 전체 코드가 궁금하신 분들을 위해 아래에 Github 주소를 링크해두었습니다.
AccessToken과 RefreshToken의 발급
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
/* /login[POST] 요청 시 진입*/
...
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
log.info("Authentication: " + principalDetails.getUsername());
return authentication;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
/*JWT Token(ACCESS, REFRESH) 생성*/
String accessToken = TokenUtil.generateToken(principalDetails.getMember().getUsername(), ACCESS);
String refreshToken = TokenUtil.generateToken(principalDetails.getMember().getUsername(), REFRESH);
...
redisService.setValues(principalDetails.getUsername(), refreshToken, JwtProperties.REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS);
}
로그인 로직을 Spring Security에서 사용하는 필터에서 구현해보고자 UsernamePasswordAuthenticationFilter를 상속받았다.
[POST] /login 요청이 오면 JwtAuthenticationFilter의 attemptAuthentication()에서 해당 요청을 받는다. (Spring Security 설정 파일에서 http.formLogin().disable()를 해주어야 한다.)
attemptAuthentication()에서 인증이 완료되면 아래의 successfulAuthentication()에서 AccessToken과 RefreshToken을 생성하여 클라이언트로 전송한다. 서버는 redis에 RefreshToken을 저장하고 클라이언트는 클라이언트 저장공간에 AccessToken과 RefreshToken을 저장한다.
데이터 요청
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
/*인가 처리 관련 로직*/
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("JwtAuthorizationFilter 진입");
/*header 값 체크 */
String jwtHeader = request.getHeader(JwtProperties.ACCESS_HEADER_STRING);
if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
chain.doFilter(request, response);
return;
}
try {
/*토큰 검증*/
String jwtToken = request.getHeader(JwtProperties.ACCESS_HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX, "");
String username = TokenUtil.verifyToken(jwtToken);
if (username != null) {
Member member = memberRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(member);
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
/*SecurityContext에 직접 접근 후 세션 생성 -> 자동으로 UserDetailsService에 있는 loadByUsername 호출*/
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
} catch (TokenExpiredException e) {
ResponseDto responseDto = ResponseDto.builder()
.status(HttpStatus.UNAUTHORIZED)
.message("Token Has Expired")
.build();
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(responseDto));
}
}
}
BasicAuthenticationFilter를 상속받은 필터를 추가해, 데이터 요청이 올 때마다 해당 필터를 거치도록 하였다.
여기서 클라이언트가 데이터 요청 시 Header에 담은 AccessToken을 검증하게 된다.
해당 AccessToken이 정상적으로 검증이 되었으면 이후 로직을 처리하게 되고 그렇지 않으면 클라이언트 측에 에러 메시지를 전송한다. AccessToken이 만료가 된 경우 클라이언트에게 토큰이 만료됨을 알린다.
AccessToken 재발급 요청
public ResponseEntity<ResponseDto> reissue(MemberRequestDto.Reissue reissue) {
String username = TokenUtil.verifyToken(reissue.getRefreshToken());
if (username == null) {
ResponseDto responseDto = ResponseDto.builder()
.status(HttpStatus.BAD_REQUEST)
.message("Invalid Refresh Token")
.build();
return new ResponseEntity<>(responseDto, HttpStatus.BAD_REQUEST);
}
username = TokenUtil.verifyToken(reissue.getAccessToken());
String redisToken = redisService.getValues(username);
if (!redisToken.isEmpty()) {
String newAccessToken = TokenUtil.generateToken(username, TokenType.ACCESS);
MemberResponseDto.TokenInfo tokenInfo = MemberResponseDto.TokenInfo.builder()
.accessToken(newAccessToken)
.build();
ResponseDto responseDto = ResponseDto.builder()
.data(tokenInfo)
.status(HttpStatus.OK)
.build();
return new ResponseEntity<>(responseDto, HttpStatus.OK);
}
ResponseDto responseDto = ResponseDto.builder()
.status(HttpStatus.BAD_REQUEST)
.message("Invalid Refresh Token")
.build();
return new ResponseEntity<>(responseDto, HttpStatus.BAD_REQUEST);
}
AccessToken의 만료 요청을 받은 클라이언트는 저장해두었던 AccessToken과 RefreshToken을 Body에 담아 서버로 전송한다.
서버에서는 RefreshToken을 검증 후 AccessToken의 Payload에 저장되어 있는 username을 조회한다.
Redis에 해당 username을 key 값으로 저장되어 있는 RefreshToken을 조회한다.
RefreshToken이 만료되어 존재하지 않다면 에러 메시지를 전송해 재로그인을 할 수 있도록 한다.
RefreshToken이 존재한다면 AccessToken을 재생성해 클라이언트로 전송한다.
결론
이전의 포스팅을 통해 JWT의 개념 및 JWT의 사용 전략에 대해 알아보았고 이번에는 직접 구현을 해보았습니다.
AccessToken과 RefreshToken을 사용하여 JWT를 구현하는 방식에는 차이가 있을 수 있지만 공통적인 진행과정(토큰의 발급 및 재발급)을 거치게 됩니다. 실제 구현한 코드는 아래의 Github 링크를 통해 확인하실 수 있습니다.
Github 전체 코드
https://github.com/cms02/Login
GitHub - cms02/Login: SpringSecurity, JWT, JPA, Redis 를 이용한 로그인 구현
SpringSecurity, JWT, JPA, Redis 를 이용한 로그인 구현. Contribute to cms02/Login development by creating an account on GitHub.
github.com