eclipse-sirius / sirius-web

Sirius Web: open-source low-code platform to define custom web applications supporting your specific visual languages
https://eclipse.dev/sirius/sirius-web.html
Eclipse Public License 2.0
73 stars 48 forks source link

Explain how to authenticate WebSocket connections #846

Open Gelio opened 2 years ago

Gelio commented 2 years ago

I am using Sirius Web to build a diagram editor (see my fork: https://github.com/Gelio/CAL-web). I have gotten to a point that I want to enable authentication and authorize users so they only see projects they own. I am using JWT-based authentication, but I don't think that should matter.

I have successfully added authentication to regular REST endpoints, as well as the GraphQL query/mutation endpoint (/api/graphql). I have trouble adding authentication to WebSocket connections (GraphQL subscriptions using the /subscriptions) endpoint.

So far I found that the Authorization header (or any other custom header, for that matter) is not passed when using WebSockets (source). WebSocket authorization has to be done in other ways, usually through sending the authentication information in one of the initial messages.

This leads me to believe that authenticating WebSocket connections is at the moment not possible without modifications to sirius-components. To be concrete, from what I found I would either need to:

  1. Register a HandshakeInterceptor for the WebSocketHandlerRegistration in https://github.com/eclipse-sirius/sirius-components/blob/ab8097c6c3593f10fdd16f9212762624a3639ccc/backend/sirius-web-spring-graphql/src/main/java/org/eclipse/sirius/web/spring/graphql/configuration/WebSocketConfiguration.java#L63-L64 and somehow set the Principal there. Some related StackOverflow questions on this

    The problem here is that custom headers are not sent by browsers in WebSocket connections, so the HTTP connection itself will be void of any authentication data, so the interceptor won't be able to set anything. The authentication data would come in a subsequent WebSocket message, but that's already past the interceptor's job.

  2. Extract the Authorization token from the payload sent in the connection_init initial WebSocket message, but that happens in GraphQLWebSocketHandler, which I believe I cannot modify from Sirius Web: https://github.com/eclipse-sirius/sirius-components/blob/ab8097c6c3593f10fdd16f9212762624a3639ccc/backend/sirius-web-spring-graphql/src/main/java/org/eclipse/sirius/web/spring/graphql/ws/GraphQLWebSocketHandler.java#L243-L256

    This would seem like the most correct solution.

From what I could tell, in Sirius Web the WebSocket connections work fine due to the SiriusWebAuthenticationFilter, which appends Authorization headers to all requests (both regular HTTP API requests and WebSocket connections). This seems like a hack that hides the fact that WebSocket requests cannot have such headers because browsers just don't send them (source). I expect that if we removed SiriusWebAuthenticationFilter and instead attached the Authorization header to all API requests, the problem with WebSocket connections missing authentication information would be surfaced.

If it is possible at the moment to authenticate WebSocket connections and I have missed something, let me know. If not, I would appreciate making the changes to allow passing authentication data to those connections

My authentication-related work is generally described https://github.com/Gelio/CAL-web/issues/96 (see https://github.com/Gelio/CAL-web/issues/96#issuecomment-980572837 for a description of the problem) and implemented so far in https://github.com/Gelio/CAL-web/pull/97. You can use the JAR built in CI (https://github.com/Gelio/CAL-web/actions/runs/1510579015) if you want to test it yourselves.

Gelio commented 2 years ago

I have used the current master of Sirius Web and moved the Authorization header to the frontend. See https://github.com/Gelio/sirius-web/commit/27d862c1796546ca64dc2adeb577991e26a114ec

GraphQL queries and mutations now correctly expect the header to be sent via the frontend and result in 401 errors if the header is not present. GraphQL subscriptions seem to work without any headers. This is not what I observed on https://github.com/Gelio/CAL-web/pull/97 where I was getting connection_error responses via WebSocket:

image

Presumably this is because on my branch with JWT authentication I removed https://github.com/eclipse-sirius/sirius-web/blob/0866885f5ed3c3f4ee758c756d4c25c2d7f8fb94/backend/sirius-web-sample-application/src/main/java/org/eclipse/sirius/web/sample/configuration/SpringWebSecurityConfiguration.java#L52

and thus, there is likely no principal set in https://github.com/eclipse-sirius/sirius-components/blob/ab8097c6c3593f10fdd16f9212762624a3639ccc/backend/sirius-web-spring-graphql/src/main/java/org/eclipse/sirius/web/spring/graphql/ws/GraphQLWebSocketHandler.java#L244-L245 which results in the errors I am seeing on my branch.

This does not solve the problem, because I want to use JWT-based authentication.

Gelio commented 2 years ago

I managed to find a workaround that lets me use GraphQL subscriptions without requiring any authentication information. See https://github.com/Gelio/CAL-web/pull/97/commits/2b0742e22adccb3144784003ea35bb39f756f5bb

The workaround is to always supply the Authorization principal for the /subscriptions GraphQL endpoint.

This is flawed because there is no authentication/authorization for that endpoint and anyone can get read-only access to any model via GraphQL subscriptions.

This makes my priority for fixing this issue lower, but I would still like to have JWT authentication enabled for GraphQL subscriptions too.

Gelio commented 2 years ago

With this WebSocket workaround (hardcoding an Authorization used for WebSockets), I faced a roadblock when trying to restrict access to projects based on username. See https://github.com/Gelio/CAL-web/pull/111/commits/fad18f5e6bc5a3b463a29c0ea8498ca83f573eb6. I could restrict access for GraphQL queries, so only projects owned by the current user are shown, but for GraphQL subscriptions when I added the username check to the https://github.com/Gelio/CAL-web/blob/fad18f5e6bc5a3b463a29c0ea8498ca83f573eb6/backend/sirius-web-persistence/src/main/resources/db/sirius-web-named-queries.properties#L6 (which is used in https://github.com/Gelio/CAL-web/blob/fad18f5e6bc5a3b463a29c0ea8498ca83f573eb6/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/editingcontext/EditingContextSearchService.java#L92-L96) query since I am using a hardcoded username in my workaround, the query always returned 0 elements.

sbegaudeau commented 2 years ago

Hi,

This leads me to believe that authenticating WebSocket connections is at the moment not possible without modifications to sirius-components.

There are a couple of proprietary product which integrate the GraphQLWebSocketHandler with an authentication system without issues. Since they are proprietary solutions, I cannot share their code source but here are a couple comments.

From what I could tell, in Sirius Web the WebSocket connections work fine due to the SiriusWebAuthenticationFilter, which appends Authorization headers to all requests (both regular HTTP API requests and WebSocket connections). This seems like a hack that hides the fact that WebSocket requests cannot have such headers because browsers just don't send them (source).

The GraphQL API over WebSocket works in Sirius Web only because of the SiriusWebAuthenticationFilter (overwise it would fail since we wouldn't have any principal as you have noticed). This class is a big hack that I have created quite quickly to solve this simple issue.

Sirius Components needs to be Spring Security aware and it needs to manipulate Principal instances at some locations. Honestly, I'm quite convinced that we could even remove that requirement entirely and it would be a great improvement. Yet at the same time, Sirius Web will not try to cover the topic of authentication and authorization. My goal was to quickly to have an Authentication instance in all use cases without having to think about it. I haven't found the time to go back and look at this old hack to see how I could improve it or even remove it entirely to replace it with a more simple Spring Security configuration.

Since Sirius Components will need a Principal, we have made it a requirement at the very top of the GraphQLWebSocketHandler instead of discovering that something is missing deep down below (it also comes from the fact that we used to do stuff with this principal there but that part of the code has been removed since it was useless anyway).

That being said, the core part of your question remain, how is this principal given to the WebSocket connection to work with the current user afterwards? As I said, I can't share proprietary code here but in some projects that I have manipulated that is just plainly handled by Spring Security itself. There's no magic, there's nothing special.

https://docs.spring.io/spring-security/site/docs/5.0.7.RELEASE/reference/html/websocket.html#websocket-authentication

24.2 WebSocket Authentication WebSockets reuse the same authentication information that is found in the HTTP request when the WebSocket connection was made. This means that the Principal on the HttpServletRequest will be handed off to WebSockets. If you are using Spring Security, the Principal on the HttpServletRequest is overridden automatically.

More concretely, to ensure a user has authenticated to your WebSocket application, all that is necessary is to ensure that you setup Spring Security to authenticate your HTTP based web application.

You just need to properly setup Spring Security.

To conclude, we have created our own integration of GraphQL Java in Spring at a time when mature and popular solutions did not exist in the Spring / GraphQL community. Our solution comes with both the GraphQLWebSocketHandler and the GraphQLController as the two entry points (WebSocket and HTTP respectively).

We now have popular solutions such as Netflix DGS or even the official Spring GraphQL project for example (which seems to converge with Netflix DGS which may reuse Spring GraphQL). We may completely remove both entry points and all of our custom GraphQL integration in favor of a standard solution like that in the future. We just did not have the time for that over the past few months. We have no interest in maintaining a custom GraphQL / Spring framework for our project which is why the CHANGELOG of the v0.5.0 had some deprecation warning for our GraphQL integration.

Gelio commented 2 years ago

Thanks for a thorough explanation, again!

You just need to properly setup Spring Security.

I believe I did that. All my regular HTTP requests for GraphQL queries and mutations are authenticated correctly, because I am able to pass the authentication token in the HTTP connection headers, which is safe. For WebSockets, there is no way that I know of to provide custom headers.

A way to circumvent that would be to pass the token in as a query parameter to the /subscriptions endpoint (e.g. /subscriptions?token=...) and then extract it in a security filter. This is not great, though, because many servers/proxies print the whole URLs in their logs, which means auth tokens would be stored in the logs.

As far as I have researched the problem of authenticating GraphQL subscriptions (here, here, and here), the standard way is to provide the token using the connectionParams of Apollo WebSocket Link, which includes the token in the connection_init message, which is the first to be sent to /subscriptions. Then, the server should unwrap that message, and as I see, some Java GraphQL solutions allow registering custom interceptors that get access to those connectionParams and are able to set the principal in Spring Security context (like this one).

I find this last piece to be missing in the proprietary GraphQL server that is implemented in sirius-components.

If I understand correctly, you're saying that you're going to focus on removing the proprietary implementation of a GraphQL server that sirius-components has, and instead use a ready-made solution like Netflix DGS or Spring GraphQL. If that allows registering custom interceptors that could intercept the token from connectionParams - great. Until that is done, however, I don't see a way to authenticate GraphQL subscriptions in sirius-components that does not involve providing the token in the query parameters.

As a workaround, I suppose sirius-components could allow registering a custom service that would be invoked with the contents of the connection_init message, and could set the necessary principal in Spring Security context based on the connectionParams. It could be invoked next to https://github.com/eclipse-sirius/sirius-components/blob/ab8097c6c3593f10fdd16f9212762624a3639ccc/backend/sirius-web-spring-graphql/src/main/java/org/eclipse/sirius/web/spring/graphql/ws/GraphQLWebSocketHandler.java#L255-L256

That requires a brief refactor of that handleTextMessage method, though. I understand it may not seem worth it given the fact you're planning on removing this code anyway. From my side, that seems like the only safe solution to authenticate GraphQL subscriptions given the current stack.

Let me know if there is a solution I am not seeing. I understand you cannot share the source code. I would appreciate even a brief description of the idea.

sbegaudeau commented 2 years ago

I thought about our integration with Spring Security yesterday and I believe that we could make Sirius Components even more flexible by removing the dependency to Spring Security from the whole project. We still have remnants of Spring Security related code here or there but they are not relevant anymore and it forces us to keep that weird hack on the Sirius Web side. It would make it even easier to integrate Sirius Components since it would not come with a pre-established opinion on the topic.

Let me know if there is a solution I am not seeing. I understand you cannot share the source code. I would appreciate even a brief description of the idea.

Honestly everything is in the Spring Security documentation link that I have provided. If the security of the HTTP layer is properly done and if it requires some authentication, then this authentication is provided for the WebSocket layer by the HTTP handshake request of the WebSocket connection. There's nothing required on the Apollo side as far as I remember, there's nothing specific to GraphQL or subscriptions, that's really a 100% Spring Security question and I'll find answers for that on the Spring Security documentation or Stack Overflow.

Gelio commented 2 years ago

If the security of the HTTP layer is properly done and if it requires some authentication, then this authentication is provided for the WebSocket layer by the HTTP handshake request of the WebSocket connection. There's nothing required on the Apollo side as far as I remember, there's nothing specific to GraphQL or subscriptions, that's really a 100% Spring Security question and I'll find answers for that on the Spring Security documentation or Stack Overflow.

Sadly, I did not find any concrete answer to this that would not involve intercepting the connection_init message, which I don't think is possible with the existing GraphQL implementation in Sirius Components. Believe me, I did spend a few hours on this topic already with no luck so far. Do you know what the answer is? If so, could you share some concrete tips?

So far in my previous comments, I believe I was pinpointing exact ways I found on how to achieve authentication for WebSocket connections and which parts of Sirius Components are preventing me from doing that, and I am being redirected to StackOverflow, which does not help