본문 바로가기
Programming/TDD Project

Pull Request 027. 로그인 / 로그아웃 로직 리팩토링하기 - Http Header 대신 Cookie를 사용해보자

by JKROH 2024. 1. 9.
반응형

 이전 글에서 JWT의 적절한 사용 방법을 고민했었다. 그 결과 인증은 Stateful하게, 인가는 Stateless하게 사용하자!는 결론을 내렸다. 중복 로그인 방지나 로그아웃을 구현하기 위해서는 Stateful한 방식을 사용하는 것이 불가피했기 때문이다. 반면 인가는 어떤가? 상대방이 보낸 토큰이 내가 만든 토큰이 맞는지만 검증할 수 있으면 굳이 서버에서 어떤 토큰이 발급되었고, 만료 시간은 얼만지는 알 필요가 없다. 따라서 JWT가 지닌 Stateless한 성질을 야무지게 사용할 수 있다.

 

 이 방식을 구현하기 위해 다른 분의 글을 참고하여 리팩토링을 진행했다. 이번 포스팅에서는 내 코드에서 수정된 부분만을 기록할 예정이며, 큰 틀은 링크를 타고 들어가 확인하길 바란다.

 

로그인

  • JwtAuthenticationFilter
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request
        , HttpServletResponse response) throws AuthenticationException {
    ObjectMapper objectMapper = new ObjectMapper();
    SignInDto signInDto = objectMapper.readValue(request.getInputStream(), SignInDto.class);

    if(redisSignInPort.alreadySignIn(signInDto.getEmail())){
        throw new CustomAuthenticationException(ExceptionCode.ALREADY_SIGN_IN);
    }

    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(signInDto.getEmail(), signInDto.getPassword());

    return authenticationManager.authenticate(authenticationToken);
}

@Override
protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain,
                                        Authentication authResult) throws IOException, ServletException {
    MemberEntity memberEntity = (MemberEntity) authResult.getPrincipal();

    String accessToken = getAccessTokenFromJwtTokenizer(memberEntity);
    String refreshToken = getRefreshTokenFromJwtTokenizer(memberEntity);

    Cookie accessTokenCookie = cookieProvider.createCookie("ACCESS_TOKEN", "Bearer+" + accessToken, jwtManager.getAccessTokenExpirationMinutes());
    Cookie refreshTokenCookie = cookieProvider.createCookie("REFRESH_TOKEN", refreshToken, jwtManager.getRefreshTokenExpirationMinutes());

    response.addCookie(accessTokenCookie);
    response.addCookie(refreshTokenCookie);
    response.setIntHeader("MemberId", (int) memberEntity.getId());

    redisSignInPort.signIn(memberEntity.getEmail(), accessToken);

    this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}

 

 먼저 JwtAuthenticationFilter부터 살펴보자. 로그인 요청이 오면 SecurityConfig에서 해당 Filter로 요청을 보낸다. 처음 Authentication 요청 즉 로그인 요청이 오면 redisSignInPort에서 해당 아이디가 로그인 상태인지를 체크한다. 만일 로그인 상태라면 이미 로그인 상태이기 때문에 예외처리한다.

 

 인증을 성공적으로 마치면 기존의 Response Header에 토큰 값을 넣어주던 로직을 쿠키를 만들어서 Response에 담아주는 방식으로 수정했다. 쿠키를 만들어주는 역할은 Cookie Provider가 담당한다. 사용자 id를 헤더에 굳이 추가해야할 필요가 있는지에 대해서는 좀 더 고민해볼 문제다. 또한, 성공적으로 로그인을 마쳤기 때문에 redis에 해당 정보를 저장하는 RedisSignInPort#signIn() 메서드를 수행한다.

 

  • Redis Adapter
@Repository
public class RedisAdapter implements RedisSignOutPort, RedisSignInPort {

    private final JwtManager jwtManager;
    private final RedisTemplate<String, String> redisTemplate;

    public RedisAdapter(JwtManager jwtManager, RedisTemplate<String, String> redisTemplate) {
        this.jwtManager = jwtManager;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void signOut(String accessToken, String email) {
        jwtManager.verifyToken(accessToken, jwtManager.encodeBase64SecretKey(jwtManager.getSecretKey()));
        redisTemplate.opsForValue().getAndDelete(email);
    }

    @Override
    public void signIn(String email, String accessToken) {
        redisTemplate.opsForValue().set(email, accessToken);
        redisTemplate.expire(email, jwtManager.getAccessTokenExpirationMinutes(), TimeUnit.MINUTES);
    }

    @Override
    public boolean alreadySignIn(String email) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(email));
    }
}

 

 Redis와 관련한 Port는 RedisAdapter에서 구현한다. signIn()메서드는 Redis Template에 로그인 한 사용자의 이메일을 key, access token을 value로 하는 값을 넣고 access token의 만료 시간만큼 저장해놓는다. alreadySignIn()메서드는 redis에 로그인 요청을 보낸 사용자의 이메일이 Redis Template에 key 값으로 존재하는지를 확인한다.

 

로그아웃

  • SignOutController
@DeleteMapping
public ResponseEntity<String> signOut(@AuthenticationPrincipal MemberEntity requestMember,
                                      HttpServletRequest request,
                                      HttpServletResponse response) {
    signOutUseCase.signOut(requestMember.getEmail(), request, response);
    log.info("# sign out");
    return ResponseEntity.ok("Signed out successfully.");
}

 

 주요한 것은 parameter값이다. 기존에는 RequestHeader에 담겨있던 토큰을 그대로 받았다면, 이제는 HttpServletRequest와 HttpServletResponse를 parameter로 받는다. 구체적인 과정은 비즈니스 로직이기 때문에 Application 계층인 UseCase에 모두 이양한다.

  • SignOutService
@Service
public class SignOutService implements SignOutUseCase {

    private final RedisSignOutPort signOutPort;
    private final CookieManager cookieManager;

    public SignOutService(RedisSignOutPort signOutPort, CookieManager cookieManager) {
        this.signOutPort = signOutPort;
        this.cookieManager = cookieManager;
    }

    @Override
    public void signOut(String email, HttpServletRequest request, HttpServletResponse response) {
        Cookie accessTokenCookie = cookieManager.resolveAccessTokenCookie(request);
        Cookie refreshTokenCookie = cookieManager.resolveRefreshTokenCookie(request);

        expireCookie(accessTokenCookie);
        expireCookie(refreshTokenCookie);

        signOutPort.signOut(accessTokenCookie.getValue().replace("Bearer+", ""), email);

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);
    }

    private void expireCookie(Cookie cookie) {
        cookie.setMaxAge(0);
        cookie.setPath("/");
    }
}

 

 먼저 HttpServletRequest에서 AccessToken이 담긴 쿠키와 RefreshToken이 담긴 쿠키를 뽑아오고 만료시킨다. 다음으로 SignOutPort#signOut()을 통해 redis와 관련한 작업을 처리한다. 마지막으로 HttpServletResponse에 만료된 쿠키를 집어넣는다. 이렇게 하면 클라이언트 측에서 해당 쿠키가 자동으로 삭제되기 때문에 로그아웃 이후 같은 토큰을 사용할 수 없다.

 

SignOutPort#signOut()은 위에서 확인할 수 있다. JwtManager를 통해 내가 발급한 토큰이 맞는지 확인하고 redis에서 해당 요청을 보낸 사용자의 email : access token 을 삭제한다. 사용자가 로그인 중이라는 정보가 삭제되는 것이다.

 

인가

  • JwtVerificationFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    try {
        Cookie accessTokenCookie = cookieManager.resolveAccessTokenCookie(request);
        String accessToken = getTokenFromCookie(accessTokenCookie);
        jwtManager.verifyToken(accessToken, jwtManager.encodeBase64SecretKey(jwtManager.getSecretKey()));
        Map<String, Object> claims = getClaims(accessToken);
        setAuthenticationToContext(claims);
    }catch (CustomAuthenticationException | SignatureException ce){
        request.setAttribute("exception", ce);
    } catch (ExpiredJwtException ee) {
        request.setAttribute("exception", ee);
    } catch (Exception e) {
        request.setAttribute("exception", e);
    }

    filterChain.doFilter(request, response);
}

private String getTokenFromCookie(Cookie accessTokenCookie) {
    return accessTokenCookie.getValue().replace("Bearer+", "");
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    if(request.getCookies() != null){
        Cookie authorization = cookieManager.resolveAccessTokenCookie(request);
        return authorization == null || !authorization.getValue().startsWith("Bearer");
    }
    return true;
}

 

 사용자의 권한을 확인하는 JwtVerificationFilter에서 수정된 점은 많지 않다. 가장 큰 점은 인가 요청이 올 때마다 redis를 찌르던 로직이 사라졌다. 이 부분이 중요하다. 더 이상 서버가 토큰의 상태성을 알 필요가 없는 것이다. 또한 해당 필터를 거치지 않는 경우를 확인하는 shouldNotFilter의 경우, AccessToken이 존재하는지를 살펴서 있다면 필터를 거치고, 그렇지 않다면 그냥 필터를 스킵한다. 또한 별도로 클라이언트에서 쿠키를 넣어보내지 않은 경우에도 해당 필터를 스킵한다.

 

테스트

  • SignOutControllerTest
@WebMvcTest(controllers = SignOutController.class,
        excludeAutoConfiguration = {SecurityAutoConfiguration.class})
@AutoConfigureRestDocs
class SignOutControllerTest {

    private final CookieManager cookieManager = new CookieManager();

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private SignOutUseCase signOutUseCase;

    @Test
    void testSignOut() throws Exception {
        String testAccessToken = "test access token";
        String testRefreshToken = "test refresh token";

        Cookie accessTokenCookie = cookieManager.createCookie("ACCESS_TOKEN", testAccessToken, 1);
        Cookie refreshTokenCookie = cookieManager.createCookie("REFRESH_TOKEN", testRefreshToken, 1);

        mockMvc.perform(
                        delete("/auth/sign-out")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .cookie(accessTokenCookie, refreshTokenCookie)
                ).andExpect(status().isOk())
                .andDo(document(
                        "sign-out",
                        getRequestPreProcessor(),
                        getResponsePreProcessor()
                ))
                .andReturn();

        verify(signOutUseCase).signOut(any(), any(), any());
    }

 

 테스트도 당연히 수정되었다. 먼저 SignOutController의 경우, 쿠키를 Request에 담아 보내기 때문에 테스트용 쿠키를 만들고 이를 Request에 담았다.

 

  • SignOutService
@ExtendWith(MockitoExtension.class)
class SignOutServiceTest {

    @Mock
    private CookieManager cookieManager;

    @Mock
    private RedisSignOutPort signOutPort;

    @InjectMocks
    private SignOutService signOutService;

    @Test
    @DisplayName("로그아웃 요청을 보내면 로그아웃에 성공한다.")
    void signOut() {
        String testAccessToken = "test access token";
        String testRefreshToken = "test refresh token";
        MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest();
        Cookie accessTokenCookie = new Cookie("ACCESS_TOKEN", testAccessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setMaxAge(1);
        accessTokenCookie.setPath("/");
        Cookie refreshTokenCookie = new Cookie("REFRESH_TOKEN", testRefreshToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setMaxAge(1);
        accessTokenCookie.setPath("/");
        mockHttpServletRequest.setCookies(accessTokenCookie, refreshTokenCookie);

        given(cookieManager.resolveAccessTokenCookie(any())).willReturn(accessTokenCookie);
        given(cookieManager.resolveRefreshTokenCookie(any())).willReturn(refreshTokenCookie);

        MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse();
        signOutService.signOut("jk@gmail.com", mockHttpServletRequest, mockHttpServletResponse);

        verify(cookieManager).resolveAccessTokenCookie(mockHttpServletRequest);
        verify(cookieManager).resolveRefreshTokenCookie(mockHttpServletRequest);
        verify(signOutPort).signOut(testAccessToken, "jk@gmail.com");

        assertThat(mockHttpServletResponse.getCookie("ACCESS_TOKEN").getMaxAge()).isEqualTo(0);
        assertThat(mockHttpServletResponse.getCookie("REFRESH_TOKEN").getMaxAge()).isEqualTo(0);
    }
}

 

 SignOutService에 이메일과 MockHttpServletRequest, MockHttpServletResponse를 보내 원하는 로직이 적절히 수행되는지 테스트 한다. 

 

 여기까지 로그인 / 로그아웃 로직의 리팩토링을 마쳤다. 사실 리팩토링을 하는 과정에서 Postman이 Cookie설정 관련해서 엄청나게 오류를 일으키는 바람에 진짜 고생을 많이했다. 또 Response Header에 넣어주던 정보를 Cookie로 옮겨담아 넣어주고, Request Header에서 뽑아오던 정보를 Cookie에서 가져오는 것도 고쳐야 할 부분이 많아 꽤 오랜 시간이 걸렸다(특히 shouldNotFilter를 신경쓰지 못해서 계속 에러가 발생해 상당히 애를 먹었다.). 테스트 코드를 작성할 때도 MockMvc는 HttpServletRequest/Response를 인자로 넘겨주지 못한다는 점을 알기까지 꽤 오랜 시간이 걸렸다.

 

 그럼에도 불구하고 이번 구현은 꽤 만족스러운데, 첫째로 JWT를 적절히 사용하고 있는가에 대한 고민이 충분히 이루어졌다는 점이 만족스럽다. 그냥 '다들 JWT 쓴다더라.', '다들 이렇게 구현한 걸 나도 따라 해봤다'에 그치지 않고, '나는 JWT를 왜 쓰고 있는지', '나는 JWT를 적절히 사용하고 있는지'를 고민해볼 수 있는 시간이었다. 또한 '더 좋은 방식을 내 코드에 어떻게 적용할 수 있는지'를 고민하고 직접 적용해봤다는 점 역시 만족스럽다. 이제야 좀 남의 코드를 베낀 인증 / 인가 처리가 아닌 내 프로젝트에 맞게 적절히 섞어서 구현한 기분이 든다.

 

 전체 코드는 링크에서 확인할 수 있다.

반응형

댓글