AccesToken 만료시간이 지났을 때
Access token이 만료되었을 때는 재발급을 위해 Refresh Token이 이용된다.
이때 클라이언트에서 재발급을 위한 요청을 보내는데, 두 가지 구현 방법이 있다.
- 인가가 필요한 요청에 대해 access token이 만료되었을 경우 401 응답을 보내고, 응답을 받은 클라이언트는 refresh 요청을 보내 새로운 access token을 발급 받은 뒤 재요청을 보낸다.
- 클라이언트가 요청을 보내기 전, access token의 payload를 통해 만료 기한을 얻고 만료 기한이 지난 토큰이라면 refresh 요청을 보낸 후에 새로운 access token을 발급받아 원래 하려던 요청을 한다.
서버에서 매번 요청마다 토큰 유효성 검사를 하는데, 어차피 만료되었는지 체크를 하고 있으므로 1번 방법으로 구현하기로 했다.
동작 시나리오
- 클라이언트는 서버에 Access token을 헤더에 담아 요청을 보낸다.
- 서버는 인증/인가를 위해 Access token에 대한 유효성과 로그아웃 여부를 검사한다.
a. 만약 서버가 요청을 수행할 충분한 권한을 가지고 있고, access token이 아직 만료되지 않았다면, 서버는 클라이언트에 대한 요청을 수행한다.
b. 만약 서버가 요청을 수행할 충분한 권한을 가지고 있지 않다면, 서버는 401 Unauthorized 응답을 보낸다.
b일 경우
b-1. 클라이언트는 401 Unauthorized 응답을 받으면, 이에 따라 refresh token을 요청 바디에 담아 서버에 다시 요청(POST /token)을 보낸다.
b-2. 서버는 클라이언트가 보낸 Refresh token을 검증하고, 새로운 Access token을 발급하여 요청을 수행한다.
시퀀스 다이어그램
Access token에 대한 유효성과 로그아웃 여부를 검사
동작 시나리오의 2번과정을 살펴보자.
요청이 들어오면 filter를 먼저 거친다.
TokenAuthenticationFilter는 Access token에 대한 유효성과 로그아웃 여부를 검사하는 filter이다.
중요하게 볼 것은 두가지다.
- 토큰 유효성 검사
- 로그아웃 여부 검사
2-1. 토큰 유효성 검사
io.jsonwebtoken:jjwt 라이브러리인 경우
- 서버는 클라이언트로에서 받은 JWT의 헤더와 페이로드를 base64로 디코딩하여 서버가 가지고 있는 개인키로 해싱하여 클라이언트에서 받은 JWT의 시그니처값이 같은지와 만료 시간을 검증한다.
- 검증이 성공하면 정상적으로 요청이 처리되며, 검증에 실패하면 위변조/만료로 간주하고 인증 실패 처리한다.
JWT 구조를 그림으로 그려서 설명해보겠다.
(서버는 JWT를 생성할 때, 헤더(Base64로 인코딩) + 페이로드(Base64로 인코딩) 를 서버의 개인키로 해싱하여 시그니처를 생성한다. 이렇게 생성된 JWT를 클라언트에게 발급한다. 클라이언트는 인코딩된 JWT값을 서버에 헤더를 담아 요청하는 것이다.)
그 다음은 코드를 통해서 보자.
- TokenAuthenticationFilter의 doFilterInternal 함수에 토큰 유효성 검사 부분
- jwtTokenProvider.validToken(token)
- JwtTokenProvider의 validToken 함수
public boolean validToken(String token){
try{
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token);
return true;
}
catch (Exception e){
return false;
}
}
저 코드를 봐서는 어떻게 유효성 검사를 하는지 모르겠다.
JWT 라이브러리를 io.jsonwebtoken사용하고 있기 때문에 분석해보자.
parseClaimsJws 메서드를 호출하면 기본적인 포맷을 검증하고, JWT를 생성할 때 사용했던 secretKey로 서명했을 때 토큰에 포함된 signature와 동일한 signature가 생성되는지와 만료 시간을 확인한다.
- io.jsonwebtoken 라이브러리의 DefaultJwtParser 함수
String jwtWithoutSignature = base64UrlEncodedHeader + '.' + base64UrlEncodedPayload;
JwtSignatureValidator validator;
try {
validator = this.createSignatureValidator(algorithm, (Key)key);
} catch (IllegalArgumentException var26) {
String algName = algorithm.getValue();
String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " + "algorithm, but the specified signing key of type " + key.getClass().getName() + " may not be used to validate " + algName + " signatures. Because the specified " + "signing key reflects a specific and expected algorithm, and the JWT does not reflect " + "this algorithm, it is likely that the JWT was not expected and therefore should not be " + "trusted. Another possibility is that the parser was configured with the incorrect " + "signing key, but this cannot be assumed for security reasons.";
throw new UnsupportedJwtException(msg, var26);
}
//가지고 있는 키로 만든 시그니처값과 클라이언트에서 요청받은 시그니처값을 확인한다.
if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
String msg = "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.";
throw new SignatureException(msg);
}
2-2. 로그아웃 검사
이유 : stateless특징을 가진 토큰에 대해서는 그 유효기간이 끝날때가지 계속해서 유효한 문제가 발생한다. 그렇기 때문에 로그아웃된 사용자의 토큰인지 알기 위해 로그아웃 검사를 한다.
로그아웃 시, Access token을 레디스에 저장하기 때문에 레디스에서 토큰을 조회하여 검사한다.
- TokenAuthenticationFilter의 doFilterInternal 함수에 로그아웃 검사 부분
//Redis에 해당 accessToken logout 여부 확인(블랙 리스트)
String isLogout = (String) redisTemplate.opsForValue().get(token);
//로그아웃 하지 않은 경우
if(StringUtils.hasText(isLogout)){
//토큰으로부터 유저 정보 가져오기
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
최종 TokenAuthenticationFilter의 doFilterInternal 함수
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//요청 헤더의 Authorization 키의 값 조회
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String token = jwtTokenProvider.getToken(authorizationHeader);
//토큰 유효성 검사
if(StringUtils.hasText(token) && jwtTokenProvider.validToken(token)){
//Redis에 해당 accessToken logout 여부 확인(블랙 리스트)
String isLogout = (String) redisTemplate.opsForValue().get(token);
//로그아웃 하지 않은 경우
if(ObjectUtils.isEmpty(isLogout)){
//토큰으로부터 유저 정보 가져오기
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
'Backend > JWT' 카테고리의 다른 글
[JWT] JWT는 어디에 담아 클라이언트와 요청/응답할까 (0) | 2024.02.27 |
---|---|
[JWT] JWT 인증/인가 적용기(3) - 로그인 (2) | 2024.02.27 |
[JWT] JWT 인증/인가 적용기(2) - Spring Security를 통한 JWT 적용 (0) | 2024.01.29 |
[JWT] JWT 인증/인가 구현기(1) - Spring Security 인증 구조 (0) | 2024.01.27 |
[JWT] Access Token과 Refresh Token이란? (0) | 2024.01.27 |