[Spring Security] warn: SPRING_SECURITY_CONTEXT did not contain a SecurityContext but contained

2023. 5. 24. 00:29Spring/Security

문제를 만나게 된 배경

현재 프로젝트의 프론트엔드는 Next.js와 Redux를 기반으로 개발하고 있다. 그리고 로그인한 유저의 인증 정보는 Redux의 스토어에 저장된다. 이 때문에 문제가 발생한다. 유저가 브라우저를 새로고침하면 스토어가 초기화되면서 로그인 인증 정보가 사라지게 된다.

인증 정보 문제 해결 방안

백엔드

  • 유저가 로그인 중인지를 확인할 수 있는 API를 제공한다
  • 쿠키에 담긴 세션 ID를 기반으로 세션을 조회한다.
  • 세션에 인증 객체가 있으면 로그인 중이었음을 알 수 있다. 인증 객체를 Body에 담아서 프론트엔드에 응답해주면 된다.
  • 없다면 로그인되지 않은 익명 유저이다. Body에 아무것도 담지 않는다.
  • 또는 세션 자체가 없다면 세션 시간이 끝나 세션이 만료된 유저이다. 이 또한 Body에 아무것도 담지 않는다.
  • 이 기능은 인증과 관련되어 있고 Spring Context까지 갈 필요가 없기 때문에 Spring Security의 FilterChain에 커스텀 필터를 구현하여 만들도록 한다.

프론트엔드

  • 로그인 상태 컴포넌트가 렌더링될 때 로그인 중이었는지를 확인하기 위한 API 요청을 보낸다.
  • 로그인 중이었으면 로그인 컴포넌트를 렌더링한다.
  • 로그인 상태가 아니었거나 세션이 만료되었으면 미로그인 컴포넌트를 렌더링한다.

구현한 필터

@Slf4j
public class LoginCheckingFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    private final RequestMatcher loginCheckingRequestMatcher = new AntPathRequestMatcher("/api/login/check", "GET");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        RequestMatcher.MatchResult matcher = loginCheckingRequestMatcher.matcher(request);
        if (matcher.isMatch()) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                log.info("세션 있음");
                Authentication authentication = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
                if (authentication != null) {
                    log.info("로그인한 인증 객체 있음");
                    LoginUser principal = (LoginUser)authentication.getPrincipal();
                    UserWithoutPassword userWithoutPassword = UserWithoutPassword.of(principal.getUser());
                    LoginCheckingSuccessResponseDto responseDto
                        = LoginCheckingSuccessResponseDto.of(userWithoutPassword);
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(response.getWriter(), responseDto);
                } else {
                    log.info("로그인하지 않은 익명 유저임");
                    LoginCheckingFailureResponseDto responseDto = LoginCheckingFailureResponseDto.of("로그인되지 않았습니다.");
                    response.setStatus(HttpServletResponse.SC_NO_CONTENT);
                    objectMapper.writeValue(response.getWriter(), responseDto);
                }
            } else {
                log.info("세션이 있었으나 만료됨");
                LoginCheckingFailureResponseDto responseDto = LoginCheckingFailureResponseDto.of("세션이 만료되었습니다.");
                response.setStatus(HttpServletResponse.SC_NO_CONTENT);
                objectMapper.writeValue(response.getWriter(), responseDto);
            }

            return;
        }

        filterChain.doFilter(request, response);
    }
}

테스트 코드

void alreadyLoginTest() throws Exception {
    //given
    final String url = "/api/login/check";

    // 로그인한 상태를 만들기 위한 세션 생성
    MockHttpSession mockHttpSession = new MockHttpSession();

    User user = userDataRepository.findByEmail("test@gmail.com").get();
    LoginUser loginUser = LoginUser.of(user, Set.of(new SimpleGrantedAuthority("ROLE_USER")));
    Authentication authentication =
        AjaxEmailPasswordAuthenticationToken.authenticated(loginUser, null, loginUser.getAuthorities());

    mockHttpSession.setAttribute(SPRING_SECURITY_CONTEXT_KEY, authentication);

    //when
    ResultActions resultActions = mockMvc.perform(get(url).session(mockHttpSession));

    //then
    resultActions
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.user").exists());
}

문제: 테스트 경고 발생

테스트는 성공적으로 수행되었고 테스트 로그를 살펴보는데 다음과 같은 경고가 발생했다.

SPRING_SECURITY_CONTEXT did not contain a SecurityContext but contained: 'AjaxEmailPasswordAuthenticationToken [Principal=io.f12.notionlinkedblog.security.login.ajax.dto.LoginUser@70b9a553, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]'; are you improperly modifying the HttpSession directly (you should always use SecurityContextHolder) or using the HttpSession attribute reserved for this class?

 

핵심은 SecurityContextHolder를 사용하지 않고 직접 HttpSession을 조작해서 발생하는 경고라는 것이다. 실제로 테스트 수행 시 MockHttpSession을 생성하고 직접 인증 객체를 세션에 주입했다. 이 경고에 대해 납득했고 이 경고를 바탕으로 Spring Security Flow에 맞게 수정하기로 결정했다. 이를 위해 Spring Security에서 세션에 값이 언제 저장되는지부터 알아야 했다.

세션에 인증 객체가 저장되는 시점

인증 객체를 SecurityContext에 저장하는 역할을 하는 필터는 SecurityContextPersistenceFilter이다.

이 필터는 이제 Deprecated 되었고 이제 SecurityContextHolderFilter를 사용해야 한다. 필자는 현재 Spring Security 5.7.7 버전으로 개발 중이기 때문에 이 필터를 사용한다.

 

이 필터의 doFilter 구현부를 보면 세션 저장 시점을 알 수 있다.

// 이 필터 대신 SecurityContextHolderFilter를 사용해야 한다.
@Deprecated
public class SecurityContextPersistenceFilter extends GenericFilterBean {

    // ...

    private SecurityContextRepository repo;

    public SecurityContextPersistenceFilter() {
        this(new HttpSessionSecurityContextRepository());
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // ...

        HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
        // 이전에 저장된 컨텍스트가 있는지 조회한다.
        SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
        try {
            SecurityContextHolder.setContext(contextBeforeChainExecution);
            if (contextBeforeChainExecution.getAuthentication() == null) {
                logger.debug("Set SecurityContextHolder to empty SecurityContext");
            }
            else {
                if (this.logger.isDebugEnabled()) {
                    this.logger
                        .debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution));
                }
            }
            chain.doFilter(holder.getRequest(), holder.getResponse());
        }
        finally {
            SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
            // Crucial removal of SecurityContextHolder contents before anything else.
            SecurityContextHolder.clearContext();
            // 여기서 세션에 인증 객체 컨텍스트가 저장된다!!
            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
            request.removeAttribute(FILTER_APPLIED);
            this.logger.debug("Cleared SecurityContextHolder to complete request");
        }
    }

    // ...
}

finally 부분을 보면 this.repo.saveContext라는 부분에서 세션에 인증 정보를 저장한다. 스프링 시큐리티는 별다른 세션 정책을 설정하지 않으면 세션 기반으로 인증 객체를 관리한다. 이를 위해 HttpSessionSecurityContextRepository를 사용한다. 이 클래스는 HttpSession 기반으로 SecurityContext를 저장하고 가져오는 역할을 수행한다.

여기서 눈치가 빠른 사람은 알겠지만 세션에는 Authentication 객체가 저장되는 것이 아니라 SecurityContext가 저장된다. 필자가 구현한 필터에 테스트에서는 Authentication 객체를 세션에 저장했다.

SecurityContext

Spring Security는 한 번의 요청 흐름 동안 기본적으로 ThreadLocal로 동작하면서 인증 객체를 SecurityContext에 담아서 관리한다. SecurityContext에 담는 이유는 다른 전략을 선택해도 SecurityContext로 처리하기 위해서이다. 기본적인 사항이지만 커스텀 필터를 구현하고 적용하면서 이 부분을 간과하였고 이런 경고를 통해 다시 한 번 기본기를 다질 수 있었다. 또한 테스트로 이러한 숨겨진 버그를 찾아낼 수 있었다. 테스트는 정말 좋은 놈이다. 테스트 사랑한다. 경고라고 무시하지 않은 필자의 태도도 좋았다.

수정한 필터

@Slf4j
public class LoginCheckingFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    private final RequestMatcher loginCheckingRequestMatcher = new AntPathRequestMatcher("/api/login/check", "GET");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        RequestMatcher.MatchResult matcher = loginCheckingRequestMatcher.matcher(request);
        if (matcher.isMatch()) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                log.info("세션 있음");
                /* 이 부분이 변경됨!!! */
                Securitycontext ctx = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY);
                Authentication authentication;
                if ((authentication = ctx.getAuthentication()) != null) {
                    log.info("로그인한 인증 객체 있음");
                    LoginUser principal = (LoginUser)authentication.getPrincipal();
                    UserWithoutPassword userWithoutPassword = UserWithoutPassword.of(principal.getUser());
                    LoginCheckingSuccessResponseDto responseDto
                        = LoginCheckingSuccessResponseDto.of(userWithoutPassword);
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(response.getWriter(), responseDto);
                } else {
                    log.info("로그인하지 않은 익명 유저임");
                    LoginCheckingFailureResponseDto responseDto = LoginCheckingFailureResponseDto.of("로그인되지 않았습니다.");
                    response.setStatus(HttpServletResponse.SC_NO_CONTENT);
                    objectMapper.writeValue(response.getWriter(), responseDto);
                }
            } else {
                log.info("세션이 있었으나 만료됨");
                LoginCheckingFailureResponseDto responseDto = LoginCheckingFailureResponseDto.of("세션이 만료되었습니다.");
                response.setStatus(HttpServletResponse.SC_NO_CONTENT);
                objectMapper.writeValue(response.getWriter(), responseDto);
            }

            return;
        }

        filterChain.doFilter(request, response);
    }
}

수정한 테스트

void alreadyLoginTest() throws Exception {
    //given
    final String url = "/api/login/check";

    // 로그인한 상태를 만들기 위한 세션 생성
    MockHttpSession mockHttpSession = new MockHttpSession();

    User user = userDataRepository.findByEmail("test@gmail.com").get();
    LoginUser loginUser = LoginUser.of(user, Set.of(new SimpleGrantedAuthority("ROLE_USER")));
    Authentication authentication =
        AjaxEmailPasswordAuthenticationToken.authenticated(loginUser, null, loginUser.getAuthorities());
    /* 이 부분이 추가됨!!! */
    SecurityContext ctx = new SecurityContextImpl(authentication);
    mockHttpSession.setAttribute(SPRING_SECURITY_CONTEXT_KEY, ctx);

    //when
    ResultActions resultActions = mockMvc.perform(get(url).session(mockHttpSession));

    //then
    resultActions
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.user").exists());
}