JWT는 Json Web Token의 약자로, 토큰의 한 종류이다. JWT는 마침표(.)를 기준으로 Header, Payload, Signature로 이루어져있다.
사용자 정보, 토큰 만료 시간 등의 claim 정보를 담고 있다.
claim은 key-value 형태로 구성되어 있으며, Payload에 여러개의 claim을 담을 수 있다.
페이로드는 서명된 파트가 아니므로 누구나 디코딩하여 데이터 열람이 가능하다. 따라서 패스워드 같은 중요한 데이터는 페이로드에 담으면 안된다.
JWT를 사용하여 아무리 암호화를 잘 해놓았다고 해도, 클라이언트와 서버 사이에서 토큰을 주고받으며 통신하는 과정에 해커에게 토큰을 탈취당해, 토큰 안에 들어있는 정보를 탈취당할 수 있다.
서명(Signature)값이 개발자가 지정한 암호화 알고리즘을 통해 암호화되더라도, 해커가 이를 악용하여 Payload 영역의 값을 알아낼 수 있다. 따라서 Payload에는 중요한 정보(ex, 비밀번호, 주민번호)를 저장해두면 안된다.
토큰 기반 인증 방식에서 토큰은 세션과 다르게 stateless 하다. 즉 서버가 상태를 보관하고 있지 않으며, 한 번 발급한 토큰에 대해 서버는 제어권을 갖고 있지 않다. 따라서 JWT 토큰이 탈취되면 해커는 사용자인척 리소스에 접근할 수 있으며, 서버측에서는 토큰 시간이 만료되길 기다릴 뿐 사용자 계정의 제어권을 해커에게 내어줄 수 밖에 없다.
앞서 언급한 JWT 토큰의 보안 문제를 해결하기 위해, Refresh Token과 Access Token으로 나누어 토큰을 발급하는 방식을 알아보자.
앞서 언급했듯이 토큰 방식은 stateless 하기 때문에 탈취당하면 서버측에서는 토큰 만료 시간을 기다리는 것 외에는 별다른 대응을 할 수 없다. 따라서 탈취 위험을 낮추기 위해 토큰 만료 시간을 짧게 설정하면 된다. 그러나 만료 시간을 짧게 설정하면 사용자는 빈번하게 재로그인을 해야하므로 번거롭다.
따라서 사용자 검증을 위한 용도인 Access Token과 Access Token을 재발급하는 용도인 Refresh Token을 발급하고, 이때 Access Token의 유효 기간은 짧게, Refresh Token의 유효 기간은 길게 설정하면 된다. 즉 Access Token의 유효 기간을 짧게 설정하여 토큰 탈취의 위험을 낮추고, Refresh Token을 통한 Access Token 재발급을 통해 유효 기간이 짧은 Access Token이 만료되었을 때 사용자가 재로그인하는 번거로움을 줄일 수 있다.
- Access Token: resource에 접근할 수 있는 필수 정보를 담은 토큰으로, 서버는 Access Token을 통해 클라이언트를 식별하고 권한을 준다. 유효 기간을 짧게 설정하여, 토큰 탈취의 위험성을 낮춘다.
- Refresh Token: 새로운 Access Token을 발급받기 위한 토큰으로, Access Token 보다 유효 기간을 길게 설정하여, Access Token이 만료됐을 때 Refresh Token을 통해 Access Token을 재발급한다. 따라서 유효 기간이 짧은 Access Token으로 인한 빈번한 재로그인이 일어나지 않게 한다.
1 . 사용자가 로그인을 한다.
2 . 서버는 입력받은 값과 회원 DB의 값을 비교하여 사용자를 확인한다.
3~4. 올바른 사용자라면, Access Token과 Refresh Token을 발급한다. 그리고 이때 일반적으로 Refresh Token을 회원 DB에 저장해둔다.
5 . 사용자는 Refresh Token은 안전한 저장소에 저장하고, 이후 로그인이 필요한 리소스에 접근할 때마다 Access Token을 헤더에 실어 요청한다.
6~7. 서버는 전달 받은 Access Token을 검증하여 올바른 토큰인 경우 리소스 접근을 허가하고 알맞은 응답을 보낸다.
8~9. 시간이 지나 Access Token이 만료된 경우, 사용자가 이전과 동일하게 Access Token을 요청 헤더에 실어 요청한다.
10~11. 서버는 Access Token이 만료됨을 확인하고 알맞은 응답을 보낸다.
12 . 사용자는 Refresh Token을 통해 Access Token 재발급 요청을 보낸다.
13 . 서버는 전달 받은 Refresh Token 자체가 유효한지, 그리고 사용자 DB에 저장된 Refresh Token와 동일한지 비교한다. 검증 결과가 유효하고 비교 결과가 동일하다면 새로운 Access Token을 발급한다. (만약 Refresh Token도 만료됐다면 로그인을 다시하고 Refresh Token과 Access Token을 새로 발급받아야 한다.)
Access Token 탈취의 위험성을 낮추기 위해 Refresh Token을 활용한다. 그런데 Refresh Token이 탈취당하면 어떻게 될까? 해커는 탈취한 Refresh Token으로 Access Token을 발급받아 사용자인척 리소스에 접근할 수 있다. Refresh Token 탈취 위험을 낮추기 위한 방법으로 RTR(Refresh Token Rotaion)에 대해 알아보자. RTR은 Refresh Token을 통해 Access Token을 재발급받을 때 Refresh Token도 재발급하는 방법이다. 즉 Refresh Token을 한 번만 사용하는 방법이다.
예를 들어 최초 로그인시 서버는 Access Token과 Refresh Token을 사용자에게 발급해준다. 그리고 이때 Refresh Token을 사용자 DB에 저장해둔다.
Access Token이 만료되어 사용자는 Refresh Token을 전달하며 새로운 Access Token 발급을 요청한다.
서버는 (1)전달받은 Refresh Token 자체의 유효성 검증과 함께 (2)DB에 저장된 사용자의 Refresh Token과 전달받은 Refresh Token을 비교한다.
3-1. 전달받은 Refresh Token이 만료된 경우 재로그인을 알린다.
3-2. 반면 전달받은 Refresh Token이 유효하고, 사용자 DB의 Refresh Token과도 일치하면 새로운 Access Token과 Refresh Token을 발급한다. 그리고 DB에 저장된 Refresh Token 값을 재발급된 Refresh Token 값으로 변경한다.
RTR 방법을 적용하면 다음과 같은 장점이 있다.
향후 해커가 Refresh Token을 탈취해서 Access Token 재발급을 요청할 경우, 전달 받은 Refresh Token의 유효성 검사 뿐만 아니라 DB에 저장되어 있던 Refresh Token과의 비교까지도 진행된다. 따라서 DB의 Refresh Token과 일치하지 않을 경우(즉 가장 최신의 Refresh Token이 아닌 이전 버전의 Refresh Token인 경우) Access Token 발급이 이뤄지지 않는다. 즉 Refresh Token이 만료되기 까지 기다리는 방법 외에도 '최근 Refresh Token이냐 탈취된 이전 버전의 Refresh Token이냐'의 검증까지 이뤄지기 때문에 좀 더 보안상 안전하다고 할 수 있다.
Refresh Token을 DB에 저장하면, Stateless 하다는 장점이 사라지는 것이 아닌가?
어느정도는 맞다. 하지만 매 요청마다 Access Token이 서버측에 전달되어야 하는 것과 달리, DB에 저장된 Refresh Token에 접근하는 일은 Access Token이 만료되어 재발급하는 경우에만 일어난다. 따라서 전체를 Stateful 하게 가져가는 것 보다는 가볍게 운영이 가능하다.
아무리 Refresh Token을 통해 Access Token의 유효 기간을 짧게 설정한다고 하더라도, 그 짧은 유효 기간 내에 Access Token을 탈취하여 악용할 수 있다. 토큰 방식은 stateless 하기 때문에 서버측에서는 Access Token이 만료되길 기다리는 방법 밖에 없다.
Refresh Token을 탈취 당하면 해커는 Refresh Token을 통해 Access Token을 발급받아 악용할 수 있다. RTR 방법을 사용해 Refresh Token을 한 번만 사용하고 버린다면 조금은 더 안전할 수는 있지만, 사용하지 않은 Refresh Token을 탈취 당한다면 해커는 1회 한정으로 Access Token을 발급받을 수 있다. 즉 어떻게 대응하나 Refresh Token이 탈취 당하면 보안상 대응할 뿐 위험에 불가피하다. 따라서 클라이언트는 Refresh Token이 탈취되지 않도록 안전하게 보관해야 한다.
JwtAuthenticationFilter는 SecurityFilterChain에 포함되며, 클라이언트가 보낸 요청의 JWT 토큰 유무를 확인하고 유효성을 검증한다. 그리고 유효할 경우 Authentication으로 변환하여 SecurityContext에 저장하는 역할을 한다. 즉 JwtAuthenticationFilter는 토큰을 검증하고 인증을 완료하는 역할을 한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtils jwtTokenUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// 요청 헤더에 토큰이 있는지 검증
String accessToken = jwtTokenUtils.getTokenFromRequest(request);
// 있다면 토큰이 유효한지 검증
jwtTokenUtils.validateToken(accessToken);
// 유효하다면 Access Token을 Authentication로 변환 후 SecurityContext에 저장
Authentication authentication = jwtTokenUtils.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException e) {
// JwtException을 request에 담기
request.setAttribute("jwtException", e);
}
filterChain.doFilter(request, response);
}
}
SecurityContext: SecurityContextHolder를 통해 SecurityContext에 접근할 수 있고, SecurityContext를 통해 Authentication에 접근할 수 있다.
Authentication: 현재 접근하는 주체의 정보와 권한을 담는 인터페이스이다. Authentication은 SecurityContext에 저장된다.
public interface Authentication extends Principal, Serializable {
// 권한 목록 읽기
Collection<? extends GrantedAuthority> getAuthorities();
// credentials(주로 비밀번호) 읽기
Object getCredentials();
Object getDetails();
// Principal 객체 읽기
Object getPrincipal();
// 인증 여부 읽기
boolean isAuthenticated();
// 인증 여부 설정
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// 주로 사용자의 ID에 해당
private final Object principal;
// 주로 사용자의 PW에 해당
private Object credentials;
// 인증 완료 전의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
// 인증 완료 후의 객체 생성
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
}
따라서 다음과 같이 JWT 유효성 검증이 완료된 후에 인증이 완료된 UsernamePasswordAuthenticationToken을 생성하여 SecurityContext에 저장한다.
@Component
public class JwtTokenUtils {
...
/*
* JWT 토큰을 통해 Authentication 생성
*/
public Authentication getAuthentication(String accessToken) {
String email = jwtParser.parseClaimsJws(accessToken).getBody().getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// email, password, 권한 정보를 통해 인증이 완료된 UsernamePasswordAuthenticationToken 생성
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
return authentication;
}
}
만약 Authentication을 SecurityContext에 저장하지 않으면 어떻게 될까?
SecurityFilterChain에서 아직 실행되지 않은 뒷단의 Filter들이 실행될 때, 어딘가에서 SecurityContext에 저장된 Authentication 객체가 있는지 확인했는데 Authentication 객체가 존재하지 않아서 결국엔 정상적인 요청 프로세스가 진행되지 못하고 클라이언트 쪽에 에러 메시지를 전송하게 된다. 따라서 JWT 검증 이후에 SecurityContext에 Authentication 객체를 반드시 저장해 주어야 한다.
JWT 유효성을 검증하는 JwtAuthenticationFilter에서는 JWT가 유효하지 않을 경우, JwtException이 발생한다.
JwtException을 처리하는 JwtExceptionFilter를 JwtAuthenticationFilter 앞단에 두는 경우를 생각해볼 수 있다. JwtAuthentication에서 JwtException이 발생하면, 그 앞단의 필터인 JwtExceptionFilter에서 예외 처리를 진행한다.
그런데 이 방법에는 문제가 있다. 우리는 SecurityFilterChain을 설정할 때, 인증이 필요하지 않은 경로를 permitAll()
로 설정한다. 그런데 이때 permitAll()
로 설정한 경로 또한 SecurityFilterChain을 타게 된다. 단지 SecurityFilterChain을 모두 탄 이후에 SecurityContext에 인증 정보인 Authentication이 없어도 AuthenticationException이 발생하지 않고 컨트롤러가 정상 호출될 뿐이다.
따라서 petmitAll()
로 설정된 경로로 호출할 경우, JwtAuthenticationFilter에서 JwtException이 발생하면 이어서 진행되지 않고, JwtExceptionFilter로 예외가 넘어오며 여기서 예외 처리를 진행한다. 즉, 인증이 필요 없는 경로임에도 토큰이 없기 때문에 JwtException이 발생하며 이에 대한 예외 응답이 나가게 된다. 이러한 한계로 인해 이어서 소개하는 예외 처리 방식을 사용했다.
스프링 시큐리티는 SecurityFilterChain을 모두 탄 이후에 SecurityContext에 인증 정보인 Authentication이 없으면 인증 예외인 AuthenticationException을 발생시킨다. 그리고 AuthenticationEntryPoint의 구현체에서 이 AuthenticationException을 처리할 수 있다.
따라서 다음과 같이 AuthenticationEntryPoint를 구현한 JwtAuthenticationEntryPoint를 정의해주었다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
// HandlerExceptionResolver에게 예외 처리를 위임
// 이때 AuthenticatonException이 아닌 request에 담긴 JwtException을 전달
resolver.resolveException(request, response, null, (JwtException) request.getAttribute("jwtException"));
}
}
이때 commence 메소드의 인자로 전달된 예외를 이용하여 예외 응답을 보내게 되면 JwtException이 아닌, AuthenticationException에 대한 예외 응답을 보내는 것이다. 따라서 발생한 JwtException을 세부적으로 관리(만료된 토큰인지, 잘못된 토큰인지 등)하고자 JwtAuthenticationFilter에서 JwtException을 catch 할 때 request.setAttribute("jwtException", e);
를 통해 request에 JwtException을 담아주었다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
...
} catch (JwtException e) {
// JwtException을 request에 담기
request.setAttribute("jwtException", e);
}
}
그리고 예외를 처리할 때 resolver.resolveException(request, response, null, (JwtException) request.getAttribute("jwtException"));
를 통해 AuthenticationException이 아닌 JwtException을 HandlerExceptionResolver를 통해 예외 처리하도록 했다.
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
// HandlerExceptionResolver에게 예외 처리를 위임
// 이때 AuthenticatonException이 아닌 request에 담긴 JwtException을 전달
resolver.resolveException(request, response, null, (JwtException) request.getAttribute("jwtException"));
}
Reference
https://doqtqu.tistory.com/275
https://tansfil.tistory.com/59
https://hudi.blog/refresh-token/
https://garonnome.tistory.com/29