Overview
- 기존에 사용했던 인증/인가에 대해 학습하고, 추후 Spring Security에 JWT 연동을 목적으로 JWT에 대해 공부한 것을 정리
Spring Session
-
HTTP 는 상태가 유지하지 않는 statless한 비접속형 프로토콜이기 때문에 서버는 클라이언트의 이전 상태를 기억하기 위한 다양한 방법이 존재한다.
- JSESSIONID
- 서버는 전달받은 세션 ID를 서버에 이미 저장되어 있는 정보와 비교해서 클라이언트를 식별하며 클라이언트의 상태를 지속적으로 유지하게 된다.
- 서버는 클라이언트의 첫 접속에 JSESSIONID를 할당해주고, 그 다음 접속에서는 JSESSIONID로 식별한다.
- 서버에서는 클라이언트의 고유한 세션 ID(JESSIONID)를 웹 브라우저에 쿠키로 전달
- 클라이언트에서는 서버에 요청 시 해당 세션 ID(JESSIONID)를 함께 전달
- Spring Session
- Spring에서는 클라이언트의 세션 정보를 관리하기 위한 API를 제공한다.
- 특징
- HttpSession
- RESTful API와 함께 작동하도록 헤더에 Session ID를 제공하므로써 WAS에 영향없이 HttpSession을 대체할 수 있도록 한다.
- WebSocket
- WebSocket 메시지를 수신할 때 HttpSession을 활성 상태로 유지하는 기능 제공
- WebSession
- Spring WebFlux의 WebSession을 대체할 수 있다.
- HttpSession
- 출처: https://spring.io/projects/spring-session
- HttpSession
- Controller에서 HttpSession을 매개변수로 정의하면, HttpSession 인터페이스의 구현체인 StandardSession 클래스 객체를 주입받는다.
- HttpSession로 선언한 인스턴스에서 getId()를 호출하면 JESSIONID를 확인할 수 있다.
@GetMapping("/hello") public String hello(HttpSession session, HttpServletRequest request) { log.info(session.getId()); // 세션 ID를 확인할 수 있다. Cookie[] cookies = request.getCookies(); Arrays.stream(cookies).forEach(cookie -> { if(cookie.getName().equals("JSESSIONID")) { log.info(cookie.getValue()); // JSESSIONID를 확인할 수 있다. sessoin.getId()와 같다 } }); return "hello spring"; }
- HttpSession의 주요 메소드
- void setAttribute(String name, Object value)
- Object getAttribute(String name)
- void removeAttribute(String name)
- Enumeration
getAttributeNames() - setMaxInactiveinterval(int second)
- Specifies the time, in seconds, between client requests before the servlet container will invalidate this session.
- String getId()
- Returns a string containing the unique identifier assigned to this session. The identifier is assigned by the servlet container and is implementation dependent.
- 출처: https://tomcat.apache.org/tomcat-9.0-doc/servletapi/javax/servlet/http/HttpSession.html
Multi Server에서 Session 관리
- 만약, 1대의 Server를 운영하던 도 중 서버의 부하를 줄이기 위해 서버를 증설했다.
- Client -> L4 or L7 Switch -> Server, Server2
- L4 또는 L7에 백엔드 API를 요청하고, L4는 트래픽을 분산하여 두 대의 백엔드 서버에 분산해서 호출한다.
- 각각의 백엔드 서버에서 관리하는 세션 정보는 서로 공유하고 있지 않기 때문에 세션 정보가 일치하지 않는 상황이 발생한다.
- 해결책 1. Sticky Session
- Sticky Session은 로드밸런서에서 클라이언트가 동일한 서버에 접속할 수 있도록 한다.
- 즉, 사용자의 세션을 1번 서버에서 생성되었다면 이 사용자의 Request는 1번 서버로만 보내지는 방식이다.
- 클라이언트는 자신의 세션 정보를 갖고 있는 특정 서버에만 접속하기 때문에 세션이 일치하지 않는 상황이 발생하지 않는다.
- 다만, 사용자의 세션이 한 서버에 의존하고 있기 때문에 트래픽을 적절히 분산시킬 수 없다.
- 해결책2. Tomcat에서 지원하는 Session Clustering
- Session Clustering이란, 여러 대의 WAS가 동일한 세션으로 세션을 관리하여 여러대의 서버가 하나의 서버처럼 연결되어 동작하는 방식이다.
- 단점은 새로운 서버를 추가할 떄마다 기존에 존재하던 WAS에 새로운 서버의 IP, Port 등을 매번 설정해서 클러스터링 해줘야 한다.
- 해결책3. Redis로 세션 서버를 따로 구축
- Redis와 같은 것을 활용하여 세션 저장소 구축
- Redis는 Remote Dictionary Server의 약자로서, “키-값” 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템(DBMS)이다.
- 모든 데이터를 메모리로 불러와서 처리하는 메모리 기반 DBMS이다.
- 새로운 서버를 추가하더라도 Redis Session Server에 연결만 해주면 되기 때문에 WAS 서버의 수정이 필요없다는 장점이 있다.
- Spring Boot + Redis: https://docs.spring.io/spring-session/docs/current/reference/html5/guides/boot-redis.html
- Redis와 같은 것을 활용하여 세션 저장소 구축
Spring Session 인증 & 인가 인터셉터 구현
-
소스 저장소: https://github.com/devHTak/SpringAuth/tree/main/UsingSession
- Interceptor 등록
- AuthInterceptor.class
@Slf4j @Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { log.info("AuthInterceptor preHandle"); if(request.getSession().getAttribute(SecurityConstants.KEY_ROLE) != null && request.getSession().getAttribute(SecurityConstants.KEY_ROLE).equals(Role.USER.name())) { return true; } throw new CustomAuthenticationException(); } }
- 인증이 완료되면 true를 리턴하고 아닌 경우 CustomAuthenticationException을 발생시킨다.
- WebMvcConfigurer.class
@Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer{ private final AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // TODO Auto-generated method stub registry.addInterceptor(authInterceptor) .addPathPatterns("/api/v1/coffees/**") .excludePathPatterns("/api/vi/login/**"); } }
- WebMvcConfigurer에 addInterceptors에서 생성한 인터셉터를 추가하며, 해당 인터셉터가 동작할 요청의 PATH를 지정한다.
- AuthInterceptor.class
- 인증 실패: Exception
- CustomAuthenticationException.class
public class CustomAuthenticationException extends RuntimeException { public CustomAuthenticationException() { super(ErrorCode.AUTHENTICATION_FAILED.getMessage()); } public CustomAuthenticationException(Exception ex) { super(ex); } }
- GlobalExceptionHandler.class
@Slf4j @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CustomAuthenticationException.class) protected ResponseEntity<CommonResponse> handleCustomAuthenticationException(CustomAuthenticationException e) { log.info("CustomAuthenticationException", e); CommonResponse response = CommonResponse.builder() .code(ErrorCode.AUTHENTICATION_FAILED.getCode()) .message(e.getMessage()) .status(ErrorCode.AUTHENTICATION_FAILED.getStatus()).build(); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); } }
- Interceptor에서 인증에 실패한 경우 CustomAuthenticationException가 발생하고, GlobalExceptionHandler에 handleCustomAuthenticationException 핸들러가 실행한다.
- 인증에 대한 예외 발생 시, CommonResponse로 리턴하여 준다.
- CustomAuthenticationException.class
- 인증 설정 - 로그인
- LoginController.class
@RestController @RequiredArgsConstructor public class LoginController { private final LoginService loginService; @PostMapping("/api/v1/login") public String login(HttpSession session, @RequestBody LoginRequestDTO loginRequestDTO) { MemberDTO member = loginService.login(loginRequestDTO.getEmail(), loginRequestDTO.getPassword()) .orElseThrow(LoginFailedException::new); session.setAttribute(SecurityConstants.KEY_ROLE, member.getRole().name()); session.setAttribute("email", member.getEmail()); session.setMaxInactiveInterval(1800); return "ok"; } }
- 로그인에 성공하면, 세션에 필요한 정보를 저장한다.
- LoginController.class
- Test Code 작성
-
LoginControllerTest.class
@SpringBootTest(webEnvironment = WebEnvironment.MOCK) @AutoConfigureMockMvc public class LoginControllerTest { @Autowired MockMvc mockMvc; @Autowired MockHttpSession session; @Autowired ObjectMapper objectMapper; @Test void loginTest() throws Exception { LoginRequestDTO loginRequestDTO = LoginRequestDTO.builder() .email("test@test.com").password("test1234").build(); MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post("/api/v1/login") .contentType(MediaType.APPLICATION_JSON_VALUE) .content(objectMapper.writeValueAsString(loginRequestDTO)) .session(session); mockMvc.perform(builder) .andDo(print()) .andExpect(status().isOk()); assertEquals(session.getAttribute("email"), "test@test.com"); } }
- MockHttpSession: JUnit 환경에서 HttpSession을 Mock 객체로 만들어 사용할 수 있다.
- MockServletRequestBuilder: 요청 헤더 객체를 만드는 데, MockHttpSession을 세션에 담겨서 보낼 수 있다. 요청에 대한 세션 유지를 위해 사용했다.
-
- 세션 저장소 단점
- 세션 저장소를 요청이 있을 때마다 조회해야 한다는 단점이 있다.
- 사용자의 리소스 조회 요청이 있을 때마다 권한이 있는지 검증을 하기 위해서, 매번 세션 저장소에 세션 데이터를 조회해야 한다.
- 출처: https://brunch.co.kr/@springboot/491