현재 진행하는 팀프로젝트에 적용할 JWT에 대해서 알아보고 적용해봅니다.
이 포스팅에서는 구현에 대한 세세한 코드를 모두 보여드리진 않습니다. JWT를 사용하고자 하지만 Refresh Token을 아직 사용해보지 못했거나 로직의 흐름을 이해하지 못한 사람들을 위해 로직의 흐름을 설명하고 이해시키는게 목적입니다! 자세한 코드는 추후에 깃헙 링크를 공유하고자 합니다.
Json Web Token의 줄임말으로써 세션 방식의 단점을 보완하기 위해 사용되는 기술입니다.
서버 측에 세션 정보를 모두 가지고 관리되는 세션 방식은 분산 서버 환경에서 정합성 문제를 해결하고, 부하를 줄일 수 있는 방법 중의 하나입니다.
자세한 내용은 예전에 작성한 글을 확인 해보면 좋습니다.
[JWT] Session과 JWT에 대해 알아보기
현재 진행하는 팀 프로젝트에서는 Redis를 사용하지 않고 RDB를 이용해 JWT를 사용할 예정입니다. (Redis 적용은 나중에)
이러한 문제점들을 해결하기 위해 RefreshToken이라는 것이 필요합니다. RefreshToken에 대한 추가적인 설명은 [JWT] Session과 JWT에 대해 알아보기에 나와있습니다.
제가 본 JWT 강의에서는 토큰을 생성하고 별도로 토큰을 저장하지 않고 그냥 복사 붙여넣기로 Postman의 Request Header에 토큰을 직접 입력했습니다.
실제 사용에서는 사용자가 일일이 토큰을 Request에 직접 넣어줄 수 없습니다.
토큰 정보를 브라우저의 storage에 저장하거나 쿠키에 저장해야됩니다.
저는 토큰을 쿠키에 저장하기로 했습니다. 그 이유도 역시 위 링크에 자세히 적혀있습니다.
JWT accessToken만 사용하는 필터의 구현은 아주 간단합니다. 아래는 JWT 강의를 보고 실습한 코드 입니다.
강의에서는 AccessToken만 사용하고 로그아웃, 토큰이 만료됐을 때 사용자가 다시 로그인해야 하는 상황에 대해서는 구현하지 않았습니다.
로직은 다음과 같습니다.
1. 인증이 필요한 경우 Request의 Header에서 토큰을 가져옵니다.
2. 토큰이 있다면 토큰을 검증하고 디코딩합니다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws
IOException,
ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
String token = getToken((HttpServletRequest)request);
if (token != null) {
try {
Jwt.Claims claims = verify(token);
log.debug("Jwt parse result: {}", claims);
String username = claims.username;
List<GrantedAuthority> authorities = getAuthorities(claims);
if (isNotEmpty(username) && authorities.size() > 0) {
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(new JwtAuthentication(token,username)
, null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(
(HttpServletRequest)request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
} catch (JWTVerificationException e) {
log.warn("Jwt processing failed: {}", e.getMessage());
}
}
} else {
log.debug("SecurityContextHolder not populated with security token, as it already contained: {}",
SecurityContextHolder.getContext().getAuthentication());
}
chain.doFilter(request, response);
}
RefreshToken을 사용할 때에는 조금 더 복잡합니다. 그리고 추가적으로 앞에서 고려사항에서 말했듯이 쿠키를 사용할 예정입니다.
제가 본 강의에서는 GenericFilterBean을 상속 받아서 커스텀 필터를 구현했는데 저는 OncePerRequestFilter를 상속 받았습니다.
이유는 다음과 같습니다.
GenericFilterBean은 매 서블릿마다 doFilter를 수행합니다. 실제로 GenericFilterBean을 상속받아서 구현하면 한번 Request를 보낼 때 여러번 JwtAuthenticationFilter를 거칩니다.
매 서블릿 마다 같은 필터를 거칠 필요는 없습니다. 인증은 단 한번만 수행하면 됩니다.
ShouldNotFilter 함수를 제공해줍니다.
회원 가입, 로그인같은 경우에는 JWT Authentication Filter를 거칠 필요가 없습니다.
OncePerRequestFilter는 ShouldNotFilter() 라는 특정 조건에 따라 Filter를 스킵하는 유용한 메서드를 제공 합니다. 필요 시에 JWT Filter만 스킵할 수 있습니다.
package com.kdt.instakyuram.security.jwt;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.kdt.instakyuram.auth.dto.TokenResponse;
import com.kdt.instakyuram.auth.service.TokenService;
import com.kdt.instakyuram.exception.EntityNotFoundException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Jwt jwt;
private final TokenService tokenService;
private final Logger log = LoggerFactory.getLogger(getClass());
public JwtAuthenticationFilter(Jwt jwt, TokenService tokenService) {
this.jwt = jwt;
this.tokenService = tokenService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
logRequest(request);
try {
authenticate(getAccessToken(request), request, response);
} catch (JwtTokenNotFoundException e) {
this.log.warn(e.getMessage());
}
filterChain.doFilter(request, response);
}
private void logRequest(HttpServletRequest request) {
log.info(String.format(
"[%s] %s %s",
request.getMethod(),
request.getRequestURI().toLowerCase(),
request.getQueryString() == null ? "" : request.getQueryString())
);
}
private String getAccessToken(HttpServletRequest request) {
if (request.getCookies() != null) {
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(this.jwt.accessTokenProperties().header()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new JwtAccessTokenNotFoundException("AccessToken is not found"));
} else {
throw new JwtAccessTokenNotFoundException("AccessToken is not found.");
}
}
private void authenticate(String accessToken, HttpServletRequest request, HttpServletResponse response) {
try {
Jwt.Claims claims = verify(accessToken);
JwtAuthenticationToken authentication = createAuthenticationToken(claims, request, accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
this.log.info("set Authentication");
} catch (TokenExpiredException exception) {
Cookie cookie = new Cookie(jwt.accessTokenProperties().header(), "");
cookie.setPath("/");
cookie.setMaxAge(0);
cookie.setHttpOnly(true);
response.addCookie(cookie);
this.log.warn(exception.getMessage());
refreshAuthentication(accessToken, request, response);
} catch (JWTVerificationException exception) {
log.warn(exception.getMessage());
}
}
private JwtAuthenticationToken createAuthenticationToken(Jwt.Claims claims, HttpServletRequest request,
String accessToken) {
List<GrantedAuthority> authorities = this.jwt.getAuthorities(claims);
if (claims.memberId != null && !authorities.isEmpty()) {
JwtAuthentication authentication = new JwtAuthentication(accessToken, claims.memberId, claims.username);
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authentication, null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
return authenticationToken;
} else {
throw new JWTDecodeException("Decode Error");
}
}
private void refreshAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response) {
try {
String refreshToken = getRefreshToken(request);
if (isValidRefreshToken(refreshToken, accessToken)) {
String reIssuedAccessToken = accessTokenReIssue(accessToken);
Jwt.Claims reIssuedClaims = verify(reIssuedAccessToken);
JwtAuthenticationToken authentication = createAuthenticationToken(reIssuedClaims, request,
reIssuedAccessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
Cookie cookie = new Cookie(this.jwt.accessTokenProperties().header(), reIssuedAccessToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(this.jwt.accessTokenProperties().expirySeconds());
response.addCookie(cookie);
} else {
log.warn("refreshToken expired");
}
} catch (JwtTokenNotFoundException | JWTVerificationException e) {
this.log.warn(e.getMessage());
}
}
private String getRefreshToken(HttpServletRequest request) {
if (request.getCookies() != null) {
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(this.jwt.refreshTokenProperties().header()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new JwtRefreshTokenNotFoundException("RefreshToken is not found."));
} else {
throw new JwtRefreshTokenNotFoundException();
}
}
private boolean isValidRefreshToken(String refreshToken, String accessToken) {
try {
TokenResponse foundRefreshToken = this.tokenService.findByToken(refreshToken);
Long memberId = this.jwt.decode(accessToken).memberId;
if (memberId.equals(foundRefreshToken.memberId())) {
this.jwt.verify(foundRefreshToken.token());
return true;
}
} catch (EntityNotFoundException | JWTVerificationException e) {
log.warn(e.getMessage());
return false;
}
return false;
}
private String accessTokenReIssue(String accessToken) {
return jwt.generateAccessToken(this.jwt.decode(accessToken));
}
private Jwt.Claims verify(String token) {
return jwt.verify(token);
}
}
로직의 흐름은 다음과 같습니다.
2-a. AccessToken이 만료되었을 경우 (refreshAuthentication())
JWT를 사용할 때 로그아웃 기능을 구현하는 대표적인 방법이 로그아웃한 AccessToken을 DB에 저장해 해당 AccessToken으로 요청이 들어오면 DB 확인을 통해서 인증을 거부하는 방식을 사용할 수 있습니다.
JWT 장점 중에 하나는 매번 Session Storage를 조회하는 세션 방식과는 다르게 토큰에 대한 정보를 통해 인증을 할 수 있다는 점이었고 RefreshToken을 사용하면서 DB를 사용하더라도 토큰이 만료되었을 때만 사용하기 때문에 세션 방식보다 큰 이점을 가져갈 수 있었습니다.
하지만 블랙리스트-로그아웃 기능을 추가한다면 AccessToken이 블랙리스트에 등록이 되었는지 확인하기위해 매번 DB를 조회하므로 JWT가 가진 이러한 장점이 퇴색될 수 있다는 결론이 났습니다.
쿠키에서 토큰을 삭제하더라도 토큰 자체는 여전히 사용할 수 있는 상태이기 때문에 중간에 탈취된다면 아무런 조취를 할 수 없다는것이 걱정되었지만 토큰의 유효시간을 짧게 설정하고 어느정도의 Trade-off를 감수하면서 JWT의 장점을 최대한 살리는게 좋다고 판단되었습니다.