platanus-kr/plata-anywhere-chat

message: 세션 스토리지 공유

Closed this issue · 2 comments

구현이 다른 WAS 에서 세션 공유 구현

이것 때문에 한달 넘게 고생함..

시작 전 생각 했던 것

1) 예상

  • web에서 Redis Session 으로 세션 백엔드를 잡아뒀으니 채팅시 세션 유효성(로그인 상태)을 검증하기 위해 message에서 토큰(JSEESIONID)으로 Redis 조회해다가 꺼내서 쓰면 되겠다.

2) 예상과 달랐던 점

  • WebSocket은 Cookie 개념이 없다.
  • Spring 에서 생성하는 JSESSIONID는 HTTP Only 속성에 포함된다.
    그래서 프론트쪽에서 함부로 꺼내 쓸 수도 없다.
  • WebSocket 연결을 하기 전에 핸드쉐이크 과정에서 HTTP 연결이 있다.
    MVC에서는 핸드쉐이크 과정에서 Cookie를 조회해 전처리기를 만들 수 있으나, WebFlux에서는 안된다. (정확하게는 안됐다)
  • JSESSIONID는 세션 스토리지를 조회하기 위한 식별자가 아니다.
    식별자를 원하면 따로 토큰 체계를 만들거나 Spring Security 의 sessionId로 작업해야 한다.
  • Java는 직렬화를 할 때 클래스 정보를 삽입하는데 여기에 클래스 경로가 포함된다.
    이때문에 구현이 다른 WAS에서 역직렬화 하기 어렵다.
  • json Serialize, Deserialize 구현은 의외로 DOM parse를 요구한다.

3) 정말로 필요했던 것

  • web에서 갖고있는 회원은 언젠가 분리돼야 했고, WAS간 세션 인증시 RPC든 REST를 사용하게 했어야 했는데 그게 지금이였다
    그러면 일이 너무 커져서 그냥 web안에서 RestContoller 구현
  • 나는 왜 세션 백센드로 Redis를 선택해서 고통받는가.

이 티켓에서 실제로 한 것

  1. 토큰 기반으로 내부 WAS 끼리 세션 인증 유효성 처리기 구현
  1. sessionId 기반으로 session storage 조회 기능 구현
  1. 회원 인증 후 websocket payload에 해당 내용 전달

자세한 내용은 아래 댓글에 있음

WebSocket 에서 인증엔 어떤 문제가 있는가.

ws 통신시 인증에 대한 문제를 겪고 있어 정리. mvc 기반의 뷰+회원기능 서비스와 webflux기반의 채팅을 주로 처리하는 서비스를 하나의 세션 스토리지를 공유하고 mvc에서 발급한 토큰(또는 세션)을 webflux에서 이용할 수 있게 하려고 한다.

어떤 식으로 인증 할까?

  1. 인증을 위해 payload에 토큰 담기

가장 간단한 방법이며 토큰을 클라이언트에서 보내기 때문에 WebSocketHandler 에서 메시지 처리시에 토큰을 이용해 유효성 검사를 하면 된다. 그러나 세션 하이제킹과 같이 클라이언트가 보내는 토큰 탈취를 검증할 수 없다.
백엔드에서 직접 발급하고 라이브하게 관리되는 세션방식을 사용할 수 있지만 JSESSIONID는 HTTP only로 발급되고 이 속성은 클라이언트가 마음대로 값을 가져올 수 없다. 이를 비활성화 한다면 앞의 문제와 같은 이슈가 발생한다.

  1. JSESSIONID '그대로' 사용하기

백엔드에서 기본적으로 생성되는 세션키를 사용할 수 있으나 앞서 이는 HTTP only로 지정되어 payload에 심을 수 없다고 했다.
그런데 ws는 http와 달리 쿠키의 개념이 없다. stateful 전이중 통신에서 인증을 하려면 payload에 토큰을 심어야 할것 같은데 이를 해결할 아이디어가 없나 찾아보니 ws에 handshake 할때 처음엔 http로 통신한다고 한다. 그래서 이를 WebFilter 로 잡고 적절한 Map에 담아 WebSocketHandler로 내려줄 생각이였다.

그런데 여기서 HTTP와 WebSocket의 프로토콜 차이를 간과했다.

두 프로토콜의 차이와 헛다리

  1. HTTP 처리와 WebSocket 처리

둘의 구현엔 아무런 연관관계가 없다. HTTP처리의 경우 ServerWebExchange를 사용하고 WebSocket은 WebSocketHandler를 사용한다. HTTP에서 SpringSecurity로 principal을 가져올 생각이였고 가져온 값을 WebSocket으로 내려줄 생각이였다.
그러나 둘의 프로토콜은 처리되는 채널이 다르기 때문에 서로 필요한 정보를 전달할 방법이 없다.

  1. 헛다리
    • WebSocket 연결 전에 HTTP에서 쿠키 따위를 가져와서 활용 할 수 있는 RequestUpgradeStrategy는 MVC에서 사용가능한 부분이라 WebFlux에서는 사용할 수 없다.
    • WebFilter에서 Spring Security를 이용하는 방법은 인증이지 세션의 처리가 아니다. 그냥 ws handshake시에 자동으로 붙는 상태임.

결정하기

  1. 세션스토리지 접근

최초 연결을 위한 WebFilter에서 Spring Security의 세션 스토리지를 조회한다. 이는 결국 세션키를 조회하려면 세션스토리지에 접근하는 CRUD를 직접 구현해야 한다. 세션스토리지에 접근하는 방법은 백엔드에 종속된다.
매 payload마다 토큰을 담아서 인증할 수 없다면 세션 만료나 로그아웃에 대응하기 위한 채팅 서비스에 별도의 처리가 필요하다.

  1. 메시지 송수신 분리

모든 채팅 통신을 websocket으로 하는게 아니라 구독 및 메시지 수신 채널로만 websocket을 사용하고, 메시지를 채팅방에 전송할때는 rest를 사용하는 아이디어.
어짜피 WebFlux로 처리 하니까 reactive한것은 변하지 않는다는 생각. 그래서 HTTP의 처리니 SpringSecurity의 세션의 처리에 대해 어플리케이션 코드베이스로 제어할 수 있을 것이라는 기대
그러나 이것은 스케일아웃시 세션 유지에 대한 또다른 복잡성이 증가한다.
ws는 WAS와 클라이언트가 1:1로 채널이 성립되니 여러대의 WAS가 있더라도 일단 연결된 채널로 전이중 통신을 할 수 있는 특징이 있다. 그러나 메시지 송신과 수신의 프로토콜을 나눠버리면 rest의 경우 http라 stateless하니 여러개의 WAS로 로드밸런싱 하는 경우 클라이언트와 연결된 채널의 WAS를 찾아가는 로드밸런서를 구성해야함.
즉 클라이언트가 처음 A라는 WAS에 ws 채널을 성립했더라도 rest로 메시지 송신시 A에서 메시지를 처리하게끔 만들어야 한다는거임. (메시지 브로커 백엔드랑 또 다른 문제임) 이미 각 WAS에서 메시지를 무작위로 받더라도 메세지 브로커 백엔드에 별도의 연결을 관리해야하는 상황에서 메시지 송수신 채널을 분리하는건 좋은 방법이 아닌것 같음.

Share session storage 계획 변경

원안은 세션을 공유 스토리지를 두고 webmessage가 같이 Spring security로 직접 session내 context를 조회해 사용한다는 계획이였다.

그러나 생각보다 문제 해결에 오래걸리고 시간적이고 기술적인 이유 두가지로 인해 계획을 변경한다.

WAS간 역직렬화기 문제

기본 직렬화기인 JdkSerializationRedisSerializer 를 사용할 시
세션을 생성하는 web 에서는 정상작동하나 message 에서는 역직렬화가 불가능하다.

사유는 다음과 같다

  1. Class 정보의 이유

Spring Redis의 기본 직렬화기인 JdkSerializationRedisSerializer 로 직렬화를 하면 class 정보가 모두 포함되는데 여기에는 class path도 포함된다. 즉, web 에서는 같은 SecurityContext를 역직렬화 하지만 message 에서는 serialVersionUID가 같더라도 class path가 다르니 역직렬화가 불가능하다.

  1. Redis에 저장되는 모든 Class의 직렬화기/역직렬화기 구현의 문제

Spring Security의 복잡성으로 생기는 문제. class path가 문제라면 Jackson의 ObjectMapper@JsonTypeInfo를 수정하면 된다는 기술적인 내용은 알았다 .그러나 SecurityContext만 직렬화기와 역직렬화기를 구현해서 될 일이 아니였다.
아래 로그인과 세션 활동 등을 통해 최종 저장되는 세션을 직접 조회해보면 SecurityContext뿐만 아니라 saveRequestauthorizationRequest도 저장되고있다. 결론적으로 GenericJackson2JsonRedisSerializer를 구성할때 이 모든 경우의 수를 구현하여 직렬화기를 구현하고, 등록해줘야 하는데 이는 Spring Security의 변경에 대해 종속되므로 적절한 개발 방향이 아닌것으로 확인된다.

Redis에 저장되는 JSON 데이터
127.0.0.1:6379> hgetall "spring:session:sessions:01c5f036-45a4-46ec-94ae-1e7a5a1135ee"
 1) "maxInactiveInterval"
 2) "1800"
 3) "sessionAttr:SPRING_SECURITY_SAVED_REQUEST"
 4) "{\"cookies\":[{\"name\":\"PAC_SESSIONID\",\"value\":\"MDFjNWYwMzYtNDVhNC00NmVjLTk0YWUtMWU3YTVhMTEzNWVl\",\"version\":0,\"comment\":null,\"domain\":null,\"maxAge\":-1,\"path\":null,\"secure\":false,\"httpOnly\":false}],\"locales\":[\"ko\"],\"contextPath\":\"\",\"method\":\"GET\",\"pathInfo\":null,\"queryString\":null,\"requestURI\":\"/test/endpoint\",\"requestURL\":\"http://localhost:3120/test/endpoint\",\"scheme\":\"http\",\"serverName\":\"localhost\",\"servletPath\":\"/test/endpoint\",\"serverPort\":3120,\"parameterNames\":[],\"parameterMap\":{},\"headerNames\":[\"accept\",\"accept-encoding\",\"accept-language\",\"connection\",\"cookie\",\"host\",\"referer\",\"sec-ch-ua\",\"sec-ch-ua-mobile\",\"sec-ch-ua-platform\",\"sec-fetch-dest\",\"sec-fetch-mode\",\"sec-fetch-site\",\"sec-fetch-user\",\"upgrade-insecure-requests\",\"user-agent\"],\"redirectUrl\":\"http://localhost:3120/test/endpoint\"}"
 5) "sessionAttr:SPRING_SECURITY_CONTEXT"
 6) "{\"authentication\":{\"authorities\":[{\"authority\":\"ROLE_USER\"}],\"details\":{\"remoteAddress\":\"0:0:0:0:0:0:0:1\",\"sessionId\":null},\"authenticated\":true,\"principal\":{\"id\":1,\"providerId\":null,\"provider\":\"web\",\"username\":\"test1\",\"password\":\"$2a$10$YzacX/DdGpGNQ85ROEn2aOQi2ZxFW2KfupkCyepVTmX//J/QjvjEK\",\"nickname\":\"\xed\x85\x8c\xec\x8a\xa4\xed\x8a\xb81\",\"profileImage\":null,\"htmlUrl\":null,\"email\":\"test1@test.com\",\"deleted\":false,\"appRole\":\"ROLE_USER\",\"lastActivated\":\"2023-05-11T14:25:34.952968\"},\"credentials\":null,\"name\":\"Member(super=org.platanus.platachat.web.member.model.Member@15f603e, id=1, providerId=null, provider=web, username=test1, password=$2a$10$YzacX/DdGpGNQ85ROEn2aOQi2ZxFW2KfupkCyepVTmX//J/QjvjEK, nickname=\xed\x85\x8c\xec\x8a\xa4\xed\x8a\xb81, profileImage=null, htmlUrl=null, email=test1@test.com, deleted=false, appRole=ROLE_USER, lastActivated=2023-05-11T14:25:34.952968)\"}}"
 7) "lastAccessedTime"
 8) "1683782780740"
 9) "creationTime"
10) "1683782766854"
11) "sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST"
12) "{\"authorizationUri\":\"https://github.com/login/oauth/authorize\",\"responseType\":{\"value\":\"code\"},\"clientId\":\"xxxxxxx\",\"redirectUri\":\"http://localhost:3120/login/oauth2/code/github\",\"scopes\":[\"read:user\"],\"state\":\"-c9d2dGH9JYxwLSK1XiuyPrVKbcf7xPewOtqAGzLp5k=\",\"additionalParameters\":{},\"authorizationRequestUri\":\"https://github.com/login/oauth/authorize?response_type=code&client_id=xxxxxxx&scope=read:user&state=-c9d2dGH9JYxwLSK1XiuyPrVKbcf7xPewOtqAGzLp5k%3D&redirect_uri=http://localhost:3120/login/oauth2/code/github\",\"attributes\":{\"registration_id\":\"github\"},\"grantType\":{\"value\":\"authorization_code\"}}"
13) "sessionAttr:member"
14) "{\"id\":1,\"provider\":\"web\",\"username\":\"test1\",\"profileImage\":null,\"htmlUrl\":null,\"nickname\":\"\xed\x85\x8c\xec\x8a\xa4\xed\x8a\xb81\",\"email\":\"test1@test.com\",\"appRole\":\"ROLE_USER\",\"token\":\"01c5f036-45a4-46ec-94ae-1e7a5a1135ee\"}"

세션 인증의 타이밍과 결국 연관되지 않은 세션의 문제

의식의 흐름

앞서 댓글의 WebSocket 프로토콜 내에서 세션 조회에 대한 결론을 결국 못냈다. 공유 세션 스토지리 시나리오가 계획대로 됐다면 채팅 메시지마다 Redis를 조회해도 괜찮았을것 같다. 하지만 이는 결국 WebSocket에서 HTTP의 개념을 사용하려는 구조적인 문제다. 이를 해결하기 위해 세션 역직렬화 부분을 커스텀 해야 하는 것으로 이어졌다. 그마저 결론 끝에 모든 객체의 역직렬화기 구현이라는 끔찍한 결론에 이르렀다.

그래서 서로 통용되지 않는 채널을 굳이 연관지어야 한다면 다음과 같은 결론을 낼 수 있다.

  1. HTTP와 WebSocket은 각 세션에 대한 고유 정보가 있다.
  2. 각 채널은 각각 자신을 특정할 수 있다.
  3. 클라이언트를 통해 WebSocket에 HTTP의 세션 ID를 받을 수 있다.
  4. WebSocket 채널 최초 생성시(예: 채팅방 입장) 세션을 관장하는 어플리케이션으로 세션 유효성을 간접 조회한다.
  5. 어플리케이션은 같은 VPC내에 있으니 일단은 REST로 조회한다.
  6. 각 채널은 자신을 특정할 수 있기 때문에 최초 연결시 세션만 연관지어 놓으면 로그아웃, 채팅방 퇴장, 강퇴 등에 채널 컨트롤을 할 수 있다.
  7. 어플리케이션의 자세한 구현이 필요하겠지만 프레임워크 수준의 구현보다는 덜 종속적라는 판단이 든다.

계획을 변경해 webmessage의 공유 세션 스토리지 계획은 파기하고, message에서 REST로 web을 조회 할 수 있는 컨트롤러와 SecurityContext 조회기를 만들고자 한다.