이번 프로젝트에서 사용하던 JWT관련 라이브러리가
java-jwt
였지만 이번 로그아웃 구현시에는jjwt
관련 라이브러리를 사용하였다.//jwt 사용 implementation group: 'com.auth0', name: 'java-jwt', version: '3.10.2' //새로추가(jjwt library) implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'
jjwt
를 사용한 이유는 기능구현에 있어 좀더 많은 편의메서드를 제공하고 이미 많은 사람들이 사용하고 있어 레퍼런스들이 많기 때문이다.
결론적으로는 이번 프로젝트에서 사용하던 Spring Security filter 중 인증 필터 기능에
JWT토큰(엑세스 토큰) 요청시 이미 로그아웃 처리된 블랙 리스트된 JWT 토큰인지를 검증하는 기능을 추가하면 된다.
또한 로그아웃 api호출시 사용하던 JWT토큰을 Redis에 저장하고 Redis의 Expiration을 JWT 토큰의 남은시간으로 세팅한다.
[로그아웃 api 호출시]
1. 엑세스(JWT)토큰의 유효기간을 알아낸다.(이미 유효기간이 끝날경우 굳이 Redis에 블랙리스트 토큰으로 지정할 필요가 없으니)
2. Redis Cache에 (key:"엑세스토큰", value:"logout", expiration:엑세스토큰 남은 기간)으로 넣는다.
(어떠한 사람들은 key로 "userId", value로 "엑세스토큰"으로 구현하였는데 지금 굳이 "userId"를 넣을 필요가 없다고 생각해서 위와 같은 방식으로 하였다. 움....맞나?🤣)
[인증 필터]
1. 기존 JWT 토큰 검증 전에 요청한 엑세스토큰이 블랙리스트에 있는지를 확인한다.
즉 Redis에 Key값으로 있는지
2. 블랙 리스트에 속한 토큰이라면 인증 실패처리를 한다.
//JWT 토큰의 만료시간
public Long getExpiration(String accessToken){
Date expiration = Jwts.parserBuilder().setSigningKey(JwtProperties.SECRET.getBytes())
.build().parseClaimsJws(accessToken).getBody().getExpiration();
long now = new Date().getTime();
return expiration.getTime() - now;
}
JWT토큰의 남은 유효시간을 얻어오는 메서드이다.
//로그아웃
@Transactional
public void logout(String accessToken){
Long findUserId = jwtProvider.getUserIdToToken(accessToken);
//엑세스 토큰 남은 유효시간
Long expiration = jwtProvider.getExpiration(accessToken);
//Redis Cache에 저장
redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);
//리프레쉬 토큰 삭제
refreshTokenRepository.delete(findUserId);
}
기존 프로젝트에 정리해둔 "BasicAuthenticationFilter"을 커스텀한 필터이다.
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
private JwtProvider jwtProvider;
private ObjectMapper objectMapper;
private UserService userService;
private RedisTemplate<String,String> redisTemplate;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider, ObjectMapper objectMapper,
UserService userService, RedisTemplate<String,String> redisTemplate) {
super(authenticationManager);
this.jwtProvider = jwtProvider;
this.objectMapper = objectMapper;
this.userService = userService;
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtProvider.validAccessTokenHeader(request);
try {
//header 에서 JWT 토큰이 있는지 검사
if(!StringUtils.hasText(token)) //토큰이 없는 경우
throw new NotExistingToken("토큰이 없습니다.");
//로그아웃된 토큰인지 검사
validBlackToken(token);
//JWT 토큰 만료기간 검증
jwtProvider.validTokenExpired(token);
if(!jwtProvider.validTokenHeaderUser(token))
throw new NotValidToken("정상적이지 않은 토큰입니다.");
Long userId = jwtProvider.getUserIdToToken(token);
User findUser = userService.findUserByUserId(userId);
PrincipalDetails principalDetails = new PrincipalDetails(findUser);
Authentication authentication = new UsernamePasswordAuthenticationToken(
principalDetails, // 나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
null, // 패스워드는 모르니까 null 처리, 어차피 지금 로그인 인증하는게 아니니까!!(로그인 필터를 사용하는게 아니니깐 지금)
principalDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication); //추가로 controller 단에서 해당 객체를 꺼낼수 있다.!
chain.doFilter(request,response);
}catch (ExpireTokenException e) { //기한만료된 토큰-201
sendResponse(response, e.getMessage(),
HttpStatus.CREATED.value(), HttpStatus.CREATED.getReasonPhrase());
return;
}catch (BlackToken e) { //로그아웃된 토큰-401
sendResponse(response, e.getMessage(),
HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
return;
} catch (NotExistingToken e){ //헤더에 토큰이 없는경우-412
sendResponse(response, e.getMessage(),
HttpStatus.PRECONDITION_FAILED.value(),HttpStatus.PRECONDITION_FAILED.getReasonPhrase() );
return;
}catch (NotValidToken e) { //정상적이지 않은 토큰-401
sendResponse(response, e.getMessage(),
HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
return;
}
catch (Exception e) { //나머지 서버 에러-500
sendResponse(response, e.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
return;
}
}
private void validBlackToken(String accessToken) {
//Redis에 있는 엑세스 토큰인 경우 로그아웃 처리된 엑세스 토큰임.
String blackToken = redisTemplate.opsForValue().get(accessToken);
if(StringUtils.hasText(blackToken))
throw new BlackToken("로그아웃 처리된 엑세스 토큰입니다.");
}
private void sendResponse(HttpServletResponse response, String message, int code, String status ) throws IOException {
BaseErrorResult result = new BaseErrorResult(message, String.valueOf(code), status);
String res = objectMapper.writeValueAsString(result);
response.setStatus(code);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(res);
}
}
프로젝트에서 구현하였던 스프링 시큐리티 인증 필터에 블랙리스트 토큰인지를 검증하기 위해 validBlackToken(token)
메서드를 정의하여 검증하였다.
메서드의 기능은 앞서 흐름도에서 설명했듯이 Redis에 해당 엑세스토큰의 Key가 있는지를 확인하게 된다.
참고자료 : https://monynony0203.tistory.com/m/105, https://sepang2.tistory.com/84