JWT Filter구현과 리프레쉬 토큰 적용 해보기

박진형·2022년 6월 20일
3

JWT 설정과 리프레쉬 토큰 적용 해보기

현재 진행하는 팀프로젝트에 적용할 JWT에 대해서 알아보고 적용해봅니다.

이 포스팅에서는 구현에 대한 세세한 코드를 모두 보여드리진 않습니다. JWT를 사용하고자 하지만 Refresh Token을 아직 사용해보지 못했거나 로직의 흐름을 이해하지 못한 사람들을 위해 로직의 흐름을 설명하고 이해시키는게 목적입니다! 자세한 코드는 추후에 깃헙 링크를 공유하고자 합니다.

JWT란?

Json Web Token의 줄임말으로써 세션 방식의 단점을 보완하기 위해 사용되는 기술입니다.
서버 측에 세션 정보를 모두 가지고 관리되는 세션 방식은 분산 서버 환경에서 정합성 문제를 해결하고, 부하를 줄일 수 있는 방법 중의 하나입니다.

자세한 내용은 예전에 작성한 글을 확인 해보면 좋습니다.
[JWT] Session과 JWT에 대해 알아보기

고려 사항

DB

현재 진행하는 팀 프로젝트에서는 Redis를 사용하지 않고 RDB를 이용해 JWT를 사용할 예정입니다. (Redis 적용은 나중에)

RefreshToken의 사용 이유

  • JWT가 가진 단점 중 하나는 액세스 토큰 탈취의 우려와, 서버 측에 세션 정보가 저장되는 세션 방식과는 다르게 서버에서 인증을 제어할 수 없어 로그아웃 기능의 구현이 어렵습니다. 또한 비교적 짧은 토큰의 만료기한 때문에 지속적으로 로그인을 다시 해줘야하는 문제점이 있습니다.
    • 액세스 토큰 탈취가 되어도 서버 측에서 액세스 토큰을 제어할 수 있는 방법이 필요합니다.
    • 로그인을 자동으로 해줄 수 있는 방법이 필요합니다.
    • 서버 측에서 클라이언트의 인증 정보를 부분적으로 제어할 수 있어야합니다.

이러한 문제점들을 해결하기 위해 RefreshToken이라는 것이 필요합니다. RefreshToken에 대한 추가적인 설명은 [JWT] Session과 JWT에 대해 알아보기에 나와있습니다.

토큰의 저장 위치

제가 본 JWT 강의에서는 토큰을 생성하고 별도로 토큰을 저장하지 않고 그냥 복사 붙여넣기로 Postman의 Request Header에 토큰을 직접 입력했습니다.

실제 사용에서는 사용자가 일일이 토큰을 Request에 직접 넣어줄 수 없습니다.

토큰 정보를 브라우저의 storage에 저장하거나 쿠키에 저장해야됩니다.

저는 토큰을 쿠키에 저장하기로 했습니다. 그 이유도 역시 위 링크에 자세히 적혀있습니다.

JwtAuthenticationFilter(커스텀 필터) 구현

JWT accessToken만 사용하는 필터의 구현은 아주 간단합니다. 아래는 JWT 강의를 보고 실습한 코드 입니다.
강의에서는 AccessToken만 사용하고 로그아웃, 토큰이 만료됐을 때 사용자가 다시 로그인해야 하는 상황에 대해서는 구현하지 않았습니다.

AccessToken만 사용

로직은 다음과 같습니다.
1. 인증이 필요한 경우 Request의 Header에서 토큰을 가져옵니다.
2. 토큰이 있다면 토큰을 검증하고 디코딩합니다.

  • 토큰이 올바르다면 JwtAuthenticationToken을 생성해 SecurityContext에 인증 정보를 저장합니다.
  • 토큰이 올바르지 않다면 예외를 발생시킵니다.
	@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 추가 사용

RefreshToken을 사용할 때에는 조금 더 복잡합니다. 그리고 추가적으로 앞에서 고려사항에서 말했듯이 쿠키를 사용할 예정입니다.

제가 본 강의에서는 GenericFilterBean을 상속 받아서 커스텀 필터를 구현했는데 저는 OncePerRequestFilter를 상속 받았습니다.

이유는 다음과 같습니다.

  1. GenericFilterBean은 매 서블릿마다 doFilter를 수행합니다. 실제로 GenericFilterBean을 상속받아서 구현하면 한번 Request를 보낼 때 여러번 JwtAuthenticationFilter를 거칩니다.
    매 서블릿 마다 같은 필터를 거칠 필요는 없습니다. 인증은 단 한번만 수행하면 됩니다.

  2. ShouldNotFilter 함수를 제공해줍니다.
    회원 가입, 로그인같은 경우에는 JWT Authentication Filter를 거칠 필요가 없습니다.
    OncePerRequestFilter는 ShouldNotFilter() 라는 특정 조건에 따라 Filter를 스킵하는 유용한 메서드를 제공 합니다. 필요 시에 JWT Filter만 스킵할 수 있습니다.

JwtAuthenticationFilter.java

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);
	}
}

로직의 흐름은 다음과 같습니다.

  1. getAccessToken()
  • 액세스 토큰을 쿠키로부터 가져 옵니다. 토큰이 없다면 예외를 발생합니다.
  1. authenticate()
  • accessToken이 있다면 토큰을 검증과 동시에 디코딩 합니다. (위변조 되진 않았는지, 토큰이 만료되진 않았는지 등)
  • 토큰이 만료되면 리프레쉬 토큰을 통해 AccessToken을 재발급 받습니다.
  • 그 외 토큰 검증 오류가 발생하면 예외를 발생시키고 종료합니다.
  • 검증에 통과 한다면 디코딩된 데이터를 토대로 authenticationToken을 만들고 SecurityContextHolder에 인증 정보를 세팅 합니다. (createAuthenticationToken(), setAuthentication())

2-a. AccessToken이 만료되었을 경우 (refreshAuthentication())

  • 쿠키로부터 RefreshToken을 찾습니다. 없다면 예외를 발생시키고 종료합니다.
  • 토큰이 있다면 검증을 합니다. (isValidRefreshToken())
    DB에 저장된 RefreshToken의 userID와 AccessToken내의 userID가 같은지 확인을 합니다.
  • 토큰이 있다면 만료된 accessToken을 디코딩해 정보를 가져오고 새 accessToken을 만드는 데 사용합니다. (accessTokenReIssue())
  • 재발급된 토큰을 다시 SecurityContextHolder에 인증 정보를 세팅해줍니다.

로그아웃 기능에 대한 생각

JWT를 사용할 때 로그아웃 기능을 구현하는 대표적인 방법이 로그아웃한 AccessToken을 DB에 저장해 해당 AccessToken으로 요청이 들어오면 DB 확인을 통해서 인증을 거부하는 방식을 사용할 수 있습니다.

JWT 장점 중에 하나는 매번 Session Storage를 조회하는 세션 방식과는 다르게 토큰에 대한 정보를 통해 인증을 할 수 있다는 점이었고 RefreshToken을 사용하면서 DB를 사용하더라도 토큰이 만료되었을 때만 사용하기 때문에 세션 방식보다 큰 이점을 가져갈 수 있었습니다.

하지만 블랙리스트-로그아웃 기능을 추가한다면 AccessToken이 블랙리스트에 등록이 되었는지 확인하기위해 매번 DB를 조회하므로 JWT가 가진 이러한 장점이 퇴색될 수 있다는 결론이 났습니다.

쿠키에서 토큰을 삭제하더라도 토큰 자체는 여전히 사용할 수 있는 상태이기 때문에 중간에 탈취된다면 아무런 조취를 할 수 없다는것이 걱정되었지만 토큰의 유효시간을 짧게 설정하고 어느정도의 Trade-off를 감수하면서 JWT의 장점을 최대한 살리는게 좋다고 판단되었습니다.

0개의 댓글