문제
먼저 기존의 Jwt전략을 헤더에 넣어서 인증하는 식으로 작성되었다. 요약하면 아래와 같다.
- 인증이 필요한 API와 필요없는 API가 있다.
- 인증이 필요없는 API는 따로 오류가 발생하지 않는다.
- 인증이 필요한 API는 jwt token이 헤더에 담겨있지 않거나 토큰 만료시간이 완료된 경우 에러가 발생한다.
해결 전략
여기서 인증이 필요한 경우 오류가 발생하는 경우가 중요하다.
- jwt token이 헤더에 담겨있지 않은 경우
위의 경우는 JwtAuthenticationFilter에서 오류를 감지하고 response에 에러를 담아 반환하게 된다.
중요한건 만료시간이 만료된 경우이다. 처음에는 현재 모든 페이지를 들릴때 user/me라는 인증을 위한 api가 전송된다. 여기서 검증하는 것은 token에 있는 유저 아이디에 해당하는 유저가 테이블에 있는지였다. 즉 만료시간은 검증하지 않았다.
jwt의 만료시간을 검증하기 위해 jwt의 verify를 사용했지만 만료시간이 지나도 에러를 발생시키지 catch에서 log만 출력하도록 작성되어 있었다. 따라서 먼저 한 것은 직접 토큰을 매개변수로 받아 만료시간이 현재시간과 비교해서 지났을 경우 401 에러를 발생시키도록 한 것이다. 이는 Filter에서 HttpServletResponse와 Request를 설정할 수 있기 때문에 가능한데 에러 발생시 catch를 하여 response 헤더에 에러메세지를 담고 상태코드를 UNAUTHORIZATION(401)을 담아 응답하게 된다.
여기서 문제가 다시 발생하는데 그러면 Refresh 전략을 어떻게 할 것인지였다. 후보는 아래와 같이 몇가지 있었다.
- refresh token을 jwt token과 동일하게 헤더에 담아 가지고 다니는 방식
- refresh token을 쿠키에 담아 가지고 다니는 방식
- refresh token을 서버에 저장하는 방식
보안을 위해 jwt token의 만료시간을 짧게 가져가고 만료시간이 긴 refresh token을 사용해 jwt token을 갱신하는 전략을 사용하였는데 1번 방식은 두 토큰 다 탈취가 될 경우 refresh token을 사용한 이유가 없어지게 된다. 왜냐하면 이전에 만료된 토큰으로 refresh 토큰과 함께 보내 새로운 access token을 받게 되기 때문이다.
두 번째 방식은 쿠키에 담아 가지고 다니는 방식이다. 위와 동일하게 탈취될 경우 어쨋든 의미가 없어지게 된다.
마지막으로 서버에 저장하는 경우이다. 이 방식은 보안에 가장 좋지만 jwt의 장점인 db를 직접 확인하지 않고 빠르게 인증을 할 수 있는 장점이 약간 무뎌지게 된다. 왜냐하면 access token이 만료될때마다 refresh token을 확인하기 위해 db를 확인하기 때문이다. 하지만 가장 보안적으로 좋고 jwt 시간도 그렇게 짧지는 않기 때문에 이 방식을 채택했다.
먼저 refresh token을 사용하지는 않고 나는 token의 user id를 이용하여 db에 저장해 조회하는 방식으로 구현하였다. 몰론 토큰정보를 저장하여 꺼내서 읽고 비교하는게 가장 좋지만 굳이 db안에서까지 토큰을 저장할 이유가 있나 싶어서 토큰을 쓰지 않았다. refresh token은 로그인 시점에 생성하는 전략을 사용하였다. 아직은 로그아웃하면 refresh token을 삭제하는 로직을 짜지는 않았다. rdb를 쓰고 refresh token이 만료되면 delete해주는 전략을 사용하였다.
위의 방식 자체는 무난하지만 delete를 할때마다의 자원과 rdb를 사용한다는 점, db 조회를 user id로 한다는 점 등 최적화 부분에서는 최악이다. rdb도 redis와 같은 메모리 디비를 사용한다거나 delete를 사용하지 않고 컬럼으로 삭제를 체크해 Batch에서 자동으로 삭제를 하는 전략등을 사용할 수 있겠지만 기한이 매우 짧았기 때문에 일단 현재 구현할 수 있는 가장 쉬운 방법으로 구현하게 되었다.
하지만 위의 방법도 문제가 있었다. 어디서 refresh가 저장된 db를 확인할 것이였다. filter에서 하기엔 db를 주입받아 확인해야 했기 때문에 filter의 장점인 db를 들리지 않는 장점이 완전히 사라져 안되었다. 남은것은 프론트쪽에서 인증을 위해 사용한 api인 user/me였다. 프론트는 모든 홈페이지를 이용하기 전에 user/me를 들리게 된다. 이후 user/me에서 기존에 user가 있는지 확인하는 로직에 더해 refresh토큰을 확인하고 jwt token을 새로 발급해주는 로직을 추가하게 되었다.
그러면 어떻게 구현했는지 보자.
구현
먼저 JwtAuthenticationFilter가 중요하다. GenricFilterBean을 상속받은 JwtFilter이며 jwt에서 만료시간 및 access token에 관련된 인증이 들어있다.
public class JwtAuthenticationFilter extends GenericFilterBean { private final Logger log = LoggerFactory.getLogger(getClass()); private final String headerKey; private final Jwt jwt; public JwtAuthenticationFilter(String headerKey, Jwt jwt) { this.headerKey = headerKey; this.jwt = jwt; } @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (SecurityContextHolder.getContext().getAuthentication() == null) { String token = getToken(request); if (token != null) { try { Jwt.Claims claims = verify(token); log.debug("Jwt parse result: {}", claims); Long userId = claims.userId; GrantedAuthority authority = getAuthorities(claims); if (isNotEmpty(userId) && authority != null) { JwtAuthenticationToken authentication = new JwtAuthenticationToken(new JwtAuthentication(token, userId), null, List.of(authority)); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } DecodedJWT decode = JWT.decode(token); if (decode.getExpiresAt().before(new Date()) && !"/api/v1/user/me".equals(request.getRequestURI())) { throw new JWTVerificationException("jwt 만료시간 초과"); } } catch (Exception e) { log.warn("Jwt processing failed: {}", e.getMessage()); request.setAttribute("exception",e); response.setHeader("error", e.getMessage()); response.setStatus(UNAUTHORIZED.value()); Map<String, String> error = new HashMap<>(); error.put("error_message", e.getMessage()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); new ObjectMapper().writeValue(response.getOutputStream(), error); } } } else { log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'", SecurityContextHolder.getContext().getAuthentication()); } chain.doFilter(request, response); } private String getToken(HttpServletRequest request) { String token = request.getHeader(headerKey); if (isNotEmpty(token)) { log.debug("Jwt authorization api detected: {}", token); return URLDecoder.decode(token.split(" ")[1], StandardCharsets.UTF_8); } return null; } private Jwt.Claims verify(String token) { return jwt.verify(token); } private GrantedAuthority getAuthorities(Jwt.Claims claims) { String role = claims.role; return role == null ? null : new SimpleGrantedAuthority(role); } }
중요한건 verify가 먹통이라는 점이다. 그래서 실제로 내가 만료시간을 확인하는 로직을 짜게 되었다. 핵심은 시간을 확인하고 예외를 catch하면 response에 401을 담아 응답한다는 점이다.
그렇다면 user/me의 서비스 로직을 보도록 하자.
@Service @RequiredArgsConstructor @Slf4j public class UserAuthenticationServiceImpl implements UserAuthenticationService { private final UserRepository userRepository; private final BicycleRepository bicycleRepository; private final AddressCodeRepository addressCodeRepository; private final JwtTokenProvider jwtTokenProvider; private final OAuthManager communicator; private final JwtRefreshTokenRepository jwtRefreshTokenRepository; @Value("${jwt 주소}") private long refreshTokenExpiryTime; private static final int MILLISECOND_CORRECTION = 1000; //시간보정 @Override @Transactional public UserMeResult checkUserById(Long id, String token) { checkArgument(isNotEmpty(id), "id must be provided."); User user = userRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Could not found user for userId")); DecodedJWT decode = JWT.decode(token); String newToken = token; if (decode.getExpiresAt().before(new Date())) { JwtRefreshToken jwtRefreshToken = jwtRefreshTokenRepository.findByUserId(id) .orElseThrow(() -> new NoSuchElementException("해당하는 리프레시 토큰 없음")); if (jwtRefreshToken.getExp().before(new Date())) { log.info("jwt 만료시간 {}", jwtRefreshToken.getExp()); jwtRefreshTokenRepository.delete(jwtRefreshToken); throw new JWTVerificationException("jwt 만료시간 초과"); } newToken = "Bearer " + jwtTokenProvider.createToken("ROLE_USER", id); } return UserMeResult.of(user.getNickname(), user.getId(), user.isRegistered(), newToken); } @Override @Transactional public OAuthLoginResult joinOAuth(String authorizationCode) throws IOException, InterruptedException { checkArgument(authorizationCode != null, "authorizationCode must be provided"); checkArgument(!authorizationCode.equals(""), "authorizationCode must be provided"); Map<String, String> oauthInformation = communicator.convertAuthorizationCodeToInfo(authorizationCode); String provider = "kakao"; String providerId = oauthInformation.get("id"); return userRepository.findByProviderAndProviderId(provider, providerId) .map(user -> { log.warn("Already exists: {} for (provider: {}, providerId: {})", user, provider, providerId); String token = generateToken(user); Date now = new Date(); if (jwtRefreshTokenRepository.findByUserId(user.getId()).isEmpty()) { jwtRefreshTokenRepository.save(new JwtRefreshToken(user.getId(), new Date(), new Date(now.getTime() + refreshTokenExpiryTime * MILLISECOND_CORRECTION))); } JwtRefreshToken jwtRefreshToken = jwtRefreshTokenRepository.findByUserId(user.getId()) .orElseThrow(() -> new NoSuchElementException("토큰을 찾을 수 없습니다.")); if (jwtRefreshToken.getExp().before(new Date())) { jwtRefreshTokenRepository.delete(jwtRefreshToken); jwtRefreshTokenRepository.save(new JwtRefreshToken(user.getId(), new Date(), new Date(now.getTime() + refreshTokenExpiryTime * MILLISECOND_CORRECTION))); } return OAuthLoginResult.of(token, false); }) .orElseGet(() -> { String nickname = oauthInformation.get("nickname"); String profileImage = oauthInformation.get("profile_image"); User user = userRepository.save(User.builder() .nickname(new Nickname(nickname)) .profileImages(profileImage) .providerId(providerId) .provider(provider) .manner(Manner.create()) .isRegistered(false) .build()); String token = generateToken(user); Date now = new Date(); jwtRefreshTokenRepository.save(new JwtRefreshToken(user.getId(), new Date(now.getTime()), new Date(now.getTime() + refreshTokenExpiryTime * MILLISECOND_CORRECTION))); return OAuthLoginResult.of(token, true); }); } private String generateToken(User user) { return jwtTokenProvider.createToken("ROLE_USER", user.getId()); } }
여기서 두가지 서비스 로직이 보이는데 oauth login을 위한 로직 , 그리고 user/me가 있다.
oauth login에서 refresh token을 발급하게 되고 user/me에서 refresh token을 이용해 jwt token을 검증하는 로직이 있다.
이렇게 구현하여 기본적인 jwt 인증 방식을 사용할 수 있게 되었다.
하지만 보안이든 최적화든 미숙한 부분이 많기 때문에 Security 및 최적화 전략에 대해 다시 공부를 하고 리팩터링이 필요하다고 생각한다. 또한 Spring에서 지원하는 OAuth관련 로직들을 거의 사용하지 못하였기 때문에 이 부분도 공식문서를 보며 리팩터링이 가능하다고 생각된다.