Spring Security + JWT 적용기 2편: JWT검증 이어지는 글입니다.
만약 사용자가 로그아웃을 하게되면 발급했던 토큰은 어떻게 관리해야 할까? 이번 시간에는 사용자가 로그아웃을 했을 때의 Security 처리에 대해 알아보자
로그아웃 흐름
- 클라이언트에서 서버로 사용자의 logout을 요청한다.
- 서버는 요청 헤더에 담긴 토큰을 검증한다.
- 검증이 되면 Redis에 저장되어 있던 Email(key)과 Refresh Token(value)을 삭제한다.
- Access Token을 key “logout” 문자열을 value로 Redis에 저장하여 해당 토큰을 Black List 처리한다.
- 사용자가 로그아웃 처리한 JWT로 요청을 보낼 경우 검증 로직을 통해 로그아웃한 사용자라면 인증 처리를 거부한다.
토큰을 삭제하면 되지 왜 Black List 처리를 하는거지?
로그인을 통해 클라이언트로 발급한 토큰은 서버에서 통제할 수 없다. 사용자가 로그아웃을 하게되면 토큰을 사용하지 못하게 해야하는데, 클라이언트가 갖고 있는 토큰은 서버에서 삭제할 수 없는 것이다.
서버에 존재하는 토큰을 삭제하더라도 클라이언트는 클라이언트가 갖고 있는 토큰으로 서버에 접근할 수 있다.
그래서 다른 방법으로 토큰을 유효하지 않게 만들어서 클라이언트가 해당 토큰을 사용하지 못하도록 해야 한다. 토큰을 유효하지 않게 만들기 위해 해당 토큰을 블랙 리스트 처리하는 방법을 알아보자
구현
AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider jwtTokenProvider;
@PatchMapping("/logout")
public ResponseEntity logout(HttpServletRequest request) {
String encryptedRefreshToken = jwtTokenProvider.resolveRefreshToken(request);
String accessToken = jwtTokenProvider.resolveAccessToken(request);
authService.logout(encryptedRefreshToken, accessToken);
return new ResponseEntity<>(new SingleResponseDto<>("Logged out successfully"), HttpStatus.NO_CONTENT);
}
}
logout() : HTTP 요청(request)에서 Access Token과 Refresh Token을 꺼내서 authService로 넘겨준다.
HttpServletRequest나 HttpServletResponse 객체가 Service 계층으로 넘어가는 것은 좋지 않다. request, response는 컨트롤러 계층에서 사용되는 객체이며, Service 계층이 request와 response를 알 필요가 없다.
Service 계층은 비즈니스 로직을 처리하고 데이터베이스와 상호작용하기 위한 역할을 담당하기 때문에 비즈니스 로직에 필요한 데이터만 인자로 전달 받도록 해야 한다.
AuthServiceImpl
public interface AuthService {
void logout(String encryptedRefreshToken, String accessToken);
}
@Service
@Transactional
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final AES128Config aes128Config;
private final RedisService redisService;
private final MemberRepository memberRepository;
@Override
public void logout(String encryptedRefreshToken, String accessToken) {
this.verifiedRefreshToken(encryptedRefreshToken);
String refreshToken = aes128Config.decryptAes(encryptedRefreshToken);
Claims claims = jwtTokenProvider.parseClaims(refreshToken);
String email = claims.getSubject();
String redisRefreshToken = redisService.getValues(email);
if (redisService.checkExistsValue(redisRefreshToken)) {
redisService.deleteValues(email);
// 로그아웃 시 Access Token Redis 저장 ( key = Access Token / value = "logout" )
long accessTokenExpirationMillis = jwtTokenProvider.getAccessTokenExpirationMillis();
redisService.setValues(accessToken, "logout", Duration.ofMillis(accessTokenExpirationMillis));
}
}
private void verifiedRefreshToken(String encryptedRefreshToken) {
if (encryptedRefreshToken == null) {
throw new BusinessLogicException(ExceptionCode.HEADER_REFRESH_TOKEN_NOT_EXISTS);
}
}
private Member findMemberByEmail(String email) {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
}
}
logout 처리 흐름을 살펴보면
- encryptedRefreshToken이 Null인지 확인
- Null이 아니라면 Aes128 복호화를 해준다. (클라이언트로 Refresh Token 전달할 때 AES128 암호화하여 전달하기 때문에 반대로 복호화해줘야 한다.)
- 복호화한 refreshToken으로 부터 Claims 객체를 파싱한다.
- Claims에 Subject로 설정했던 Email을 조회한다.
- Redis에서 Email을 Keyr값으로 갖고 있는 Refresh Token을 조회한다.
- Redis에서 조회한 redisRefreshToken이 존재한다면 Redis에서 삭제해준다.
- Redis에 Access Token을 Black List 처리하여 저장한다.(key = Access Token, value = “logout’) Access Token과 동일한 만료시간을 설정해두어 클라이언트에 발급한 Access Token이 만료된 이후 Black List의 Access Token도 만료되도록 설정한다.
JwtVerificationFilter
로그아웃한 사용자가 서버에 요청을 했을 때 어떻게 처리하는지 알아보자
@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
...
// JWT 인증 정보를 현재 쓰레드의 SecurityContext에 저장(가입/로그인/재발급 Request 제외)
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("JwtVerificationFilter.doFilterInternal excute");
try {
String accessToken = jwtTokenProvider.resolveAccessToken(request);
if (StringUtils.hasText(accessToken) && doNotLogout(accessToken)
&& jwtTokenProvider.validateToken(accessToken, response)) {
setAuthenticationToContext(accessToken);
}
// TODO: 예외처리 리팩토링
} catch (RuntimeException e) {
if (e instanceof BusinessLogicException) {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(ErrorResponse.of(((BusinessLogicException) e).getExceptionCode()));
response.getWriter().write(json);
response.setStatus(((BusinessLogicException) e).getExceptionCode().getStatus());
}
}
filterChain.doFilter(request, response);
}
private boolean doNotLogout(String accessToken) {
String isLogout = redisService.getValues(accessToken);
return isLogout.equals("false");
}
...
private void setAuthenticationToContext(String accessToken) {
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("# Token verification success!");
}
}
doFilterInternal() 메서드의 if문을 살펴보면 이전 JWT 검증편에서 언급했던 doNotLogout() 메서드를 볼 수 있다.
private boolean doNotLogout(String accessToken) {
String isLogout = redisService.getValues(accessToken);
return isLogout.equals("false");
}
@Transactional(readOnly = true)
public String getValues(String key) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
if (values.get(key) == null) {
return "false";
}
return (String) values.get(key);
}
만약 accessToken을 조회했을 때 “false”가 반환된다면 토큰을 블랙 리스트 처리한 것이 아니기 때문에 로그아웃한 사용자가 아닌것이고 만약 값이 조회된다면 로그아웃한 사용자라는 뜻이기 때문에 if문을 통과하지 못하거 인증 처리를 거부하게 된다.
테스트
로그아웃을 테스트해보자.
class AuthIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/auth";
private final String EMAIL = "email@gmail.com";
@Autowired
private MemberService memberService;
@Autowired
private RedisService redisService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void befroeEach() {
MemberDto.SignUp signUpDto = StubData.MockMember.getSignUpDto();
memberService.signUp(signUpDto);
}
@AfterEach
void afterEach() {
memberService.deleteMember(EMAIL);
redisService.deleteValues(accessToken);
}
@Test
@DisplayName("로그아웃")
void logoutTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
redisService.setValues(EMAIL, refreshToken, Duration.ofMillis(10000));
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL + "/logout")
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.patchRequestWithToken(mvc, uri, accessToken, encryptedRefreshToken);
// then
String redisRefreshToken = redisService.getValues(EMAIL);
String logout = redisService.getValues(accessToken);
assertThat(redisRefreshToken).isEqualTo("false");
assertThat(logout).isEqualTo("logout");
actions
.andExpect(status().isNoContent())
.andDo(document("logout"));
}
}
// then을 보자 logout 요청을 한 이후 테스트를 통과하는 조건은 다음과 같다.
- Redis에 redisRefreshToken이 존재하지 않아야 한다.(”false” == null)
- Access Token이 블랙 리스트 처리되어 있어야 한다.
- 응답 status로 No Content를 반환한다.
테스트가 무사히 통과되었다!
위 통합 테스트에 대해 자세하게 정리한 내용은 아래 링크를 통해 확인할 수 있다.
마무리
다음은 Access Token Reissue에 대해 알아보자
'Spring' 카테고리의 다른 글
Spring - Redis를 사용해보자 (2) | 2023.04.15 |
---|---|
Spring - Spring Security + JWT 4편: Access Token 재발급 (2) | 2023.04.14 |
Spring - Spring Security + JWT 적용기 2편: JWT 검증 (0) | 2023.04.14 |
Spring - Spring Security + JWT 적용기 1편: 로그인 (5) | 2023.04.14 |
Spring - 나의 첫 통합 테스트(Integration Test) (0) | 2023.04.12 |