제 프로젝트에서 Spring Security로 구현한 인증 로직에서는, 인증이 필요한 엔드포인트에 접근할 때 JSESSIONID가 쿠키에 포함되어 있는지를 검사하는 로직이 있었습니다. 하지만 같은 도메인에 접근하는 경우, 모든 요청에 쿠키가 함께 전송되기 때문에 문제가 발생했습니다.
이 글에서는 grafana를 사용할 때 grafana_session이라는 쿠키의 path가 '/'로 되어있어서, Spring Security Session Filter에서 쿠키 검사 시 인증이 되지 않았던 문제 상황과 해결 과정을 소개합니다.
문제 상황
위처럼 FilterChain에서 permitAll()이 설정되어 있는 링크를 접속했더니
이런 예외를 발생시켰다.
아니 왜 permitAll이 설정되어 있는 엔드포인트 접근인데 인증이 필요한 걸까?
그런데 inPrivate나 다른 브라우저에서는 접근이 됐다.
문제 원인 찾기
위 코드에서. addFilterBefore는 UsernamePasswordAuthenticationFilter.class가 실행되기 전에, 커스텀 SessionFilter를 실행하는 동작을 한다.
(참고로 UsernamePasswordAuthenticationFilter는 form 기반 login에서 /login URL에 대한 POST 요청을 처리하도록 Spring Security에 자동으로 설정되어 있다.)
그래서 SessionFilter를 보니, request.getCookies()!= null 이 참이 되고 있었다.
그리고 email, password가 null이어서 인증 문제가 발생한 것이다.
즉, 다음과 같이 동작하도록 SessionFilter가 구현되어 있었다.
- HttpServletRequest에서 쿠키가 있다.
- 그런데 jSessionIdCookie가 비어있다.
- 그럼 jSessionIdCookie를 할당하기 위해 사용자가 email, password를 제공해야 한다.
- 그런데 둘 다 null이다.
그러면 inPrivate에서 왜 됐고, 일반 창에서는 왜 whilelabel이 발생했는가?
grafana_session Cookie
위 요청 헤더의 차이를 보면 Cookie의 유무 차이가 있는 것을 확인할 수 있다.
따라서 SessionFilter에 로직에 있던
- HttpServletRequest에서 쿠키가 있다.
- 그런데 jSessionIdCookie가 비어있다.
- 그럼 jSessionIdCookie를 할당하기 위해 사용자가 email, password를 제공해야 한다.
- 그런데 둘 다 null이다.
4번에서 email, password가 null이어서 인증할 수 없다는 Exception이 발생한 것이다.
그런데 grafana는 localhost:3000을 사용하고 있는데 왜 localhost:8080의 path들을 접근할 때 브라우저가 이 Cookie를 포함한 것 인가 찾아보았다.
쿠키 정보를 보면 Domain은 localhost, Path는 '/'이다. 즉 이는 localhost이라는 Domain의 모든 Path에 이 쿠키를 담아서 보내겠다는 것. 또한 브라우저의 쿠키는 Port 별로 나누지 않는다. 따라서 이 쿠키를 SecurityFilter가 걸러낸 것이 문제였다.
문제의 해결: 로직의 수정
애초에 쿠키가 존재하면 무조건 Email, Password를 찾고 Exception을 발생시키는 것은 옳지 않다고 판단했다.
그래서 기존 Security에 구현된 로직들을 수정했다.
다시 기존 로직을 보면
- HttpServletRequest에서 쿠키가 있다.
- 그런데 jSessionIdCookie가 비어있다.
- 그럼 jSessionIdCookie를 할당하기 위해 사용자가 email, password를 제공해야 한다.
- 그런데 둘 다 null이다.
이 1~4번의 과정이 애초에 필요 없다.
왜냐하면 위 로직의 의도는 다른 인증이 필요한 엔드포인트는 로그인이 되어있는지 확인하기 위함인데,
처음부터 Login이 성공하면 클라이언트에게 JSESSIONID를 할당하고, SecurityContextHolder에 그 Authentication을 저장한다.
그리고 Login 시 Spring Security는 HttpSession에서 JSESSIONID에 따라 SecurityContextHolder에 Authentication을 가져오고, FilterChain을 수행한다. 따라서 addFilterBefore()에 Filter를 따로 적용할 필요 없다.
수정 내용은 다음과 같다.
LoginAuthenticaionFilter 구현
이 프로젝트에서는 Spring Security의 formLogin()을 사용하고 있지 않고, @ResponseBody로 데이터를 받고 DTO를 응답하도록 구현되어 있기 때문에 직접 login 인증의 관한 Filter를 추가했다.
이 클래스가 UsernamePasswordAuthenticationFilter를 상속받는 이유는 Spring Security에서 기본적으로 제공하는 로그인 처리 필터이기 때문이다.
attemptAuthentication 메서드는 FilterChain이 실행되면서 수행할 메서드로, 여기서 로그인 로직을 구현한다.
ObjectMapper(). readValue(request.getInputStream(), UserLoginRequestDto.class)를 사용하면, request Body의 데이터를 DTO 클래스에 매핑해 준다.
UsernamePasswordAuthenticationToken을 사용하여 인증 토큰을 생성하고, 이를 AuthenticationManager를 통해 인증 처리한다.
AuthenticationManager.authenticate();
AuthenticationManager는 Authentication을 처리하는 방법을 정의한 API로, 이를 구현한 ProviderManager는 Spring Security Filter로부터 받은 Authentication 객체를 가지고 실제 인증을 수행하는 AuthenticationProvider를 찾아 위임하는 역할을 한다.
여기서 authenticate 메서드가 수행하는 작업들은 다음과 같다.
- 인증 토큰 검증: authenticate() 메서드는 전달된 Authentication 객체의 유형에 따라 적절한 AuthenticationProvider를 선택한다. Spring Security는 여러 개의 AuthenticationProvider를 사용할 수 있으며, 각 AuthenticationProvider는 특정 유형의 인증 작업을 처리하도록 설계되어 있다.
- 적절한 AuthenticationProvider 선택: AuthenticationManager는 등록된 AuthenticationProvider 리스트를 순회하면서, 전달된 Authentication 객체를 처리할 수 있는 AuthenticationProvider를 찾는다. 각 AuthenticationProvider는 자신이 처리할 수 있는 Authentication 객체의 타입을 확인한다.
- 인증 시도: 적절한 AuthenticationProvider가 선택되면, 이 AuthenticationProvider가 authenticate() 메서드를 호출하여 실제 인증 작업을 수행한다. 일반적으로 이 단계에서 다음과 같은 작업들이 수행된다:
- 사용자 정보 조회: UserDetailsService 등을 사용하여 데이터베이스나 다른 저장소에서 사용자 정보를 조회한다.
- 비밀번호 검증: 입력된 비밀번호와 저장된 비밀번호(암호화된 형태)를 비교한다.
- 계정 상태 확인: 계정이 잠겨 있거나 비활성화된 상태인지 확인한다.
- 인증 성공 처리: 인증이 성공하면, AuthenticationProvider는 인증된 Authentication 객체를 반환한다. 이 객체는 사용자의 권한(roles, authorities) 및 기타 세부 정보를 포함한다. UsernamePasswordAuthenticationToken의 경우, authenticated 플래그가 true로 설정된다.
- 인증 실패 처리: 인증이 실패하면, AuthenticationProvider는 AuthenticationException 예외를 일으킨다. 예를 들어, 비밀번호가 틀린 경우 BadCredentialsException이 던져질 수 있다.
- 결과 반환: AuthenticationManager는 성공적으로 인증된 Authentication 객체를 반환한다.
즉, 별도의 비밀번호를 검사한다던가, DB에 저장되어 있는 User 데이터를 불러오는 코드를 작성할 필요가 없이 이 메서드에서 다 해준다.
따라서 여기까지가 기본적인 로그인 요청 시 아이디와 비밀번호를 확인하는 기능을 수행한다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authentication) throws IOException {
SecurityContextHolder.getContext().setAuthentication(authentication);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;");
response.setCharacterEncoding("UTF-8");
UserResponseDto userResponseDto = userMapper.toUserResponseDto((User) authentication.getPrincipal());
String jsonResponse = objectMapper.writeValueAsString(userResponseDto);
PrintWriter writer = response.getWriter();
writer.write(jsonResponse);
writer.flush();
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
writer.write(ErrorMessage.INVALID_CREDENTIALS.getMessage());
writer.flush();
}
위 두 메서드는 각각 authenticate()가 성공적으로 처리되었을 때와 실패했을 때 실행되는 메서드이다.
successfulAuthentication에서는 성공한 Authentication을 SecurityContextHolder에 저장한다.
HttpServletResponse 객체에 응답 코드와, ContentType을 설정하고 UserResponseDto를 body에 쓰는 기능을 수행한다.
반대로 unsuccessfulAuthentication은 인증이 실패할 때 실행되며, 여기서는 커스텀한 advice를 throw 하도록 구현했다.
SecurityConfig 수정하기
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
LoginAuthenticationFilter loginAuthenticationFilter = new LoginAuthenticationFilter(
authenticationManager(http.getSharedObject(AuthenticationConfiguration.class)), userService, objectMapper);
loginAuthenticationFilter.setFilterProcessesUrl("/auth/login");
위에서 구현한 LoginAuthenticationFilter 클래스를 생성하고, 어느 URL에서 실행할 Filter인지를 setFilterProcessesUrl로 명시한다.
여기서 authenticationManager(http.getSharedObject(AuthenticationConfiguration.class))는 AuthenticationManager를 인자로 전달하기 위해 추출하는 메서드이며 다음과 같은 과정이 수행된다.
- http.getSharedObject(AuthenticationConfiguration.class):
- HttpSecurity 클래스는 보안 설정을 구성하는 데 사용된다. 이 클래스는 다양한 보안 설정 구성 요소 간에 상태와 데이터를 공유하기 위해 공유 객체(Shared Object)를 사용한다. HttpSecurity를 통해 설정된 여러 Configuration 클래스나 Builder들은 이러한 공유 객체를 참조하여 서로 간의 정보를 공유한다.
- authenticationManager(authenticationConfiguration):
- AuthenticationConfiguration 공유 객체를 사용하여 AuthenticationManager 인스턴스를 가져온다.
authorize.requestMatchers("/login", "/register")
.permitAll()
.anyRequest().permitAll())
.addFilter(loginAuthenticationFilter)
이제 FilterChain에서 위와 같이. addFilter()의 인자로 커스텀한 Filter를 넘기면, Login 작업을 하는 Filter를 추가한다.
마치며
이거 때문에 Arc 브라우저에 Support 팀에 연락도 했다.
"Dear Arc Support Team, 이거 브라우저 문제 같은데요?"
하지만 곧 브라우저 문제가 아님을 깨닫고 즉각 사과 이메일을 보냈다.
보통 Spring Security의 formLogin을 사용하면 더 간단한데, login page로 이동할 url과 실제로 login 기능을 요청할 url을 잘 설정하고 요청을 보낼 parameter 이름이 일치하도록 소통이 잘 이루어져야 한다.
또한 위와 같은 구현을 간단하게 만들어보았다. 왜냐하면 구글링에서 찾아볼 수 있는 Spring Security를 사용한 로그인, 로그아웃 기능은 대부분이 JWT +. formLogin을 사용했는데, Session 기반의 @ResponseBody로 DTO를 받아서 로그인, 로그아웃을 수행하는 방식이 있다는 것을 공유하기 위함이다.