[재능교환소] 프로젝트에서 로그아웃 서비스를 구현해보자.
직전 포스팅에서 로그인할 때 RDS에서 Redis로 RefreshToken 저장소를 변경하였었다. [재능교환소] RDS에서 Redis로 RefreshToken 저장소 전환
사실 성능향상, 간편한 만료 관리, 단순한 데이터 구조 등등 많은 이유 때문에 변경한 것도 있지만 로그아웃 처리를 위해 했다고 한 점도 있었다.
그렇다면 Redis를 어떻게 로그아웃 처리하는데 사용되는지 한번 살펴보자.
요청 헤더에서 발급되어있는 JWT로 구현한 AccessToken을 가져온다.
발급되어있는 JWT 토큰의 유효기간을 가져온다.
SecurityContextHolder에 등록되어있는 정보에서 user의 id를 가져온다.
로그인할 때 key(userId) : value(RefreshToken) 형식으로 저장했기 때문에 가져온 userId(key)로 redis에 value가 존재하는지 체크한다.
존재한다면 redis에 저장되어있는 RefreshToken 삭제한다.
[ 블랙리스트 생성 단계 ] AccessToken으로부터 가져온 key(JWT 토큰) : value("logout")으로 Redis에 저장한다.
Filter에서 Redis에 요청받은 AccessToken이 존재하는지 체크한다.
AccessToken이 있다면 404 응답을 보내며 계정에 다시 로그인 하라는 메세지를 보낸다.
유저가 서비스 사용을 마치고 로그아웃을 하면 AccessToken이 더 이상 쓰이면 안된다.
하지만 AccessToken에 유효기간이 아직 남아있다면..? 그런 AccessToken을 누군가가 탈취하여 사용하면 어떻게 되는가?
물론 짧은 시간동안 발급되는 AccessToken이라고 해도, 해당 유효기간 안에 누군가가 사용한다면.. 그야말로 보안 측면에서 최악이다!
이를 위해 블랙리스트 개념을 도입한 것이다.
유저가 로그아웃 시 AccessToken의 남은 유효기간만큼 Redis에 유효기간을 설정해 블랙리스트로 등록해놓는 것이다.
Redis는 유효기간이 끝나면 자동으로 삭제되기 때문에 메모리에 남지 않는다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
public class UserController {
private final AuthService authService;
... (중략)
/**
* 로그아웃
*/
@PatchMapping("/logout")
public UserDto.ResponseBasic logout(HttpServletRequest request) {
return authService.logout(request);
}
}
매개변수로 HTTP 요청(request)를 받아 authService에서 비즈니스 로직을 처리한다.
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
public void setBlackList(String key, Object o, Duration minutes) {
redisTemplate.opsForValue().set(key, o, minutes);
}
public Object getBlackList(String key) {
return redisTemplate.opsForValue().get(key);
}
}
로그아웃을 위해서 블랙리스트 관련 메소드들을 만들어 둔다.
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final JwtService jwtService;
private final RefreshRepository refreshRepository;
private final RedisUtil redisUtil;
...(중략)
@Override
public UserDto.ResponseBasic logout(HttpServletRequest request) {
String token = request.getHeader(" ").substring(7);
Date date = jwtService.extractExpiration(token);
Long now = new Date().getTime();
Long expiration = date.getTime() - now;
String id = securityUtil.getCurrentMemberUsername();
if (refreshRepository.findById(id).isPresent()) { //리프레시 토큰 삭제
refreshRepository.deleteById(id);
}
redisUtil.setBlackList(token, "logout", Duration.ofMillis(expiration)); //accessToken 블랙리스트 생성
return new UserDto.ResponseBasic(200, "로그아웃 되었습니다.");
}
}
요청 헤더에 Authorization
이름을 가진 헤더의 값을 가져와 Bearer
를 제외한 문자열을 token에 담는다.
AccessToken의 유효시간을 추출하는 메서드 extractExpiration를 만들어 date에 담았다.
그리고 유효시간에서 현재시간을 뺀 시간을 expiration에 담는다.
RefreshToken이 Redis에 존재한다면 삭제해준다.
현재 가져온 AccessToken(=key)를 Redis에 "logout"(=value)로 설정해주고, expiration을 ttl로 저장하여 블랙리스트를 작성하였다.
마지막으로 필터에서 로그아웃 된 사용자의 AccessToken 요청이 다시 들어왔을 때 처리를 확인해보자.
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthFilterService extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
private final RefreshRepository refreshRepository;
private final RedisUtil redisUtil;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
//Authorization 이름을 가진 헤더의 값을 꺼내옴
final String authHeader = request.getHeader("Authorization");
String jwt;
//authHeader가 null이고, Bearer로 시작하지 않다면 체인 내의 다음 필터를 호출
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
return;
}
// authHeader의 `Bearer `를 제외한 문자열 jwt에 담음
jwt = authHeader.substring(7);
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
String blackListValue = (String) redisUtil.getBlackList(jwt);
//accessToken이 블랙리스트에 등록되었는지 확인
if (blackListValue != null && blackListValue.equals("logout")) {
// 블랙리스트에 등록된 토큰인 경우 예외 처리
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User has been logged out");
return;
}
//accessToken이 만료되었다면
if ( jwtService.isTokenExpired(jwt)) {
//쿠키의 refreshToken과 db에 저장된 refreshToken의 만료일을 확인하고 accessToken 재발급 / 만료되면 재로그인 exception
handleExpiredToken(request, response);
} else {
//accessToken이 만료되지 않았다면 인증정보 등록
authenticateUser(jwt, request, response);
}
}
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
}
private void handleExpiredToken(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String refreshToken = extractRefreshTokenFromCookie(request);
if (refreshToken != null) {
Refresh refresh = refreshRepository.findByRefreshToken(refreshToken);
if (refresh != null) {
User user = userRepository.findWithAuthoritiesById(refresh.getUserId()).orElseThrow(() -> UserNotFoundException.EXCEPTION);
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
user.getId(),
"",
true,
true,
true,
true,
user.getAuthorities()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
private String extractRefreshTokenFromCookie(HttpServletRequest request) {
// 쿠키에서 refreshToken 가져오기
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private void authenticateUser(String jwt, HttpServletRequest request, HttpServletResponse response) {
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
jwtService.extractUsername(jwt),
"",
true,
true,
true,
true,
jwtService.getAuthorities(jwt)
);
//UsernamePasswordAuthenticationToken 대상을 생성 (사용자이름,암호(=null로 설정),권한)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//헤더에 accessToken 유효하므로 동일하게 설정
response.setHeader("Authorization", "Bearer " + jwt);
}
}
여기서 집중해서 봐야할 부분은 다음 코드이다.
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
String blackListValue = (String) redisUtil.getBlackList(jwt);
//accessToken이 블랙리스트에 등록되었는지 확인
if (blackListValue != null && blackListValue.equals("logout")) {
// 블랙리스트에 등록된 토큰인 경우 예외 처리
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User has been logged out");
return;
}
//accessToken이 만료되었다면
if ( jwtService.isTokenExpired(jwt)) {
//쿠키의 refreshToken과 db에 저장된 refreshToken의 만료일을 확인하고 accessToken 재발급 / 만료되면 재로그인 exception
handleExpiredToken(request, response);
} else {
//accessToken이 만료되지 않았다면 인증정보 등록
authenticateUser(jwt, request, response);
}
}
요청된 AccessToken이 Redis에 존재하고, 그 값이 "logout"이라는 문자열을 가지고 있다면 블랙리스트로 등록된 것이다.
그렇다면 응답코드로 401을 보낸다.
.addFilterBefore(authFilterService, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(configurer -> configurer
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
)
위 코드는 Spring Security의 Filter Chain 일부이다.
응답코드로 401를 보냈으므로 인증 예외 시 처리를 담당하는 엔트리 포인트인 authenticationEntryPoint
로 이동해 클라이언트에게 계정에 다시 로그인 하라는 메세지를 보낸다.
Spring Security의 Filter Chain 과정을 자세히 알고싶다면 다음 포스팅을 참고하자.
로그아웃이 크게 어렵지 않을 것이라고 생각했지만 나의 오산이었다.
AccessToken에 더불어 RefreshToken까지 도입하였기에 보안적인 측면으로 생각해야 될 점이 많았다.
그래도 JWT를 사용해보며 다양하게 생각해볼 수 있었고, 블랙리스트도 도입해보며 더욱 안전한 시스템을 설계할 수 있었다.
구현할 때 제대로 개념을 알고, 보안과 로직을 구체적으로 작성하고 정리하는게 중요하다는 것을 다시 한번 느꼈다. 👏👏