Add websocket-authentication sample

This commit is contained in:
rstoyanchev
2024-05-23 16:44:22 +01:00
parent aae7d8fe59
commit e0da3247fe
20 changed files with 750 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.example.greeting;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GreetingApplication {
public static void main(String[] args) {
SpringApplication.run(GreetingApplication.class, args);
}
}

View File

@@ -0,0 +1,25 @@
package com.example.greeting;
import java.net.URI;
import org.springframework.graphql.client.WebSocketGraphQlClient;
import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient;
public class GreetingClient {
public static void main(String[] args) {
WebSocketGraphQlClient graphQlClient = WebSocketGraphQlClient
.builder(URI.create("ws://localhost:8080/graphql"), new ReactorNettyWebSocketClient())
.interceptor(JwtGraphQlClientInterceptor.create())
.build();
graphQlClient.document("subscription {greetings}")
.retrieveSubscription("greetings")
.toEntity(String.class)
.take(5)
.doOnNext(System.out::println)
.blockLast();
}
}

View File

@@ -0,0 +1,26 @@
package com.example.greeting;
import java.time.Duration;
import reactor.core.publisher.Flux;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
@Controller
public class GreetingController {
@QueryMapping
String greeting(Authentication authentication) {
return "Hello " + authentication.getName() + "!";
}
@SubscriptionMapping
Flux<String> greetings(Authentication authentication) {
return Flux.interval(Duration.ofMillis(50))
.map((num) -> "Hello " + authentication.getName() + num + "!");
}
}

View File

@@ -0,0 +1,58 @@
package com.example.greeting;
import java.io.IOException;
import java.io.InputStream;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import reactor.core.publisher.Mono;
import org.springframework.core.io.ClassPathResource;
import org.springframework.graphql.client.WebSocketGraphQlClientInterceptor;
import org.springframework.security.converter.RsaKeyConverters;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
public final class JwtGraphQlClientInterceptor implements WebSocketGraphQlClientInterceptor {
private final String token;
private JwtGraphQlClientInterceptor(String token) {
this.token = token;
}
@Override
public Mono<Object> connectionInitPayload() {
return Mono.just(Map.of("Authorization", "Bearer " + this.token));
}
public static JwtGraphQlClientInterceptor create() {
return new JwtGraphQlClientInterceptor(initToken());
}
private static String initToken() {
ClassPathResource privResource = new ClassPathResource("simple.priv");
ClassPathResource pubResource = new ClassPathResource("simple.pub");
try (InputStream priv = privResource.getInputStream(); InputStream pub = pubResource.getInputStream()) {
RSAPublicKey publicKey = RsaKeyConverters.x509().convert(pub);
RSAPrivateKey privateKey = RsaKeyConverters.pkcs8().convert(priv);
RSAKey key = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
JwtEncoder encoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(key)));
JwtClaimsSet set = JwtClaimsSet.builder().subject("Markey").claim("scope", "greeting:read").build();
return encoder.encode(JwtEncoderParameters.from(set)).getTokenValue();
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}

View File

@@ -0,0 +1,34 @@
package com.example.greeting;
import java.security.interfaces.RSAPublicKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.server.WebSocketGraphQlInterceptor;
import org.springframework.graphql.server.support.BearerTokenAuthenticationExtractor;
import org.springframework.graphql.server.webflux.AuthenticationWebSocketInterceptor;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@ConditionalOnMissingClass("org.springframework.web.servlet.DispatcherServlet")
public class WebFluxSecurityConfig {
@Bean
SecurityWebFilterChain webFilters(ServerHttpSecurity http) {
http.authorizeExchange((authorize) -> authorize.anyExchange().permitAll());
return http.build();
}
@Bean
public WebSocketGraphQlInterceptor authenticationInterceptor(@Value("classpath:simple.pub") RSAPublicKey pub) {
return new AuthenticationWebSocketInterceptor(
new BearerTokenAuthenticationExtractor(),
new JwtReactiveAuthenticationManager(NimbusReactiveJwtDecoder.withPublicKey(pub).build()));
}
}

View File

@@ -0,0 +1,34 @@
package com.example.greeting;
import java.security.interfaces.RSAPublicKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.server.WebSocketGraphQlInterceptor;
import org.springframework.graphql.server.support.BearerTokenAuthenticationExtractor;
import org.springframework.graphql.server.webmvc.AuthenticationWebSocketInterceptor;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@ConditionalOnClass(name = "org.springframework.web.servlet.DispatcherServlet")
public class WebMvcSecurityConfig {
@Bean
SecurityFilterChain filters(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
return http.build();
}
@Bean
public WebSocketGraphQlInterceptor authenticationInterceptor(@Value("classpath:simple.pub") RSAPublicKey pub) {
return new AuthenticationWebSocketInterceptor(
new BearerTokenAuthenticationExtractor(),
new ProviderManager(new JwtAuthenticationProvider(NimbusJwtDecoder.withPublicKey(pub).build())));
}
}

View File

@@ -0,0 +1,4 @@
@NonNullApi
package com.example.greeting;
import org.springframework.lang.NonNullApi;

View File

@@ -0,0 +1,10 @@
spring.application.name=websocket-authentication
spring.graphql.websocket.path=/graphql
spring.graphql.graphiql.enabled=true
logging.level.com.example.greeting=DEBUG
logging.level.org.springframework.graphql=TRACE
logging.level.org.springframework.security=TRACE
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:simple.pub

View File

@@ -0,0 +1,7 @@
type Query {
greeting: String!
}
type Subscription {
greetings: String!
}

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDcWWomvlNGyQhA
iB0TcN3sP2VuhZ1xNRPxr58lHswC9Cbtdc2hiSbe/sxAvU1i0O8vaXwICdzRZ1JM
g1TohG9zkqqjZDhyw1f1Ic6YR/OhE6NCpqERy97WMFeW6gJd1i5inHj/W19GAbqK
LhSHGHqIjyo0wlBf58t+qFt9h/EFBVE/LAGQBsg/jHUQCxsLoVI2aSELGIw2oSDF
oiljwLaQl0n9khX5ZbiegN3OkqodzCYHwWyu6aVVj8M1W9RIMiKmKr09s/gf31Nc
3WjvjqhFo1rTuurWGgKAxJLL7zlJqAKjGWbIT4P6h/1Kwxjw6X23St3OmhsG6HIn
+jl1++MrAgMBAAECggEBAMf820wop3pyUOwI3aLcaH7YFx5VZMzvqJdNlvpg1jbE
E2Sn66b1zPLNfOIxLcBG8x8r9Ody1Bi2Vsqc0/5o3KKfdgHvnxAB3Z3dPh2WCDek
lCOVClEVoLzziTuuTdGO5/CWJXdWHcVzIjPxmK34eJXioiLaTYqN3XKqKMdpD0ZG
mtNTGvGf+9fQ4i94t0WqIxpMpGt7NM4RHy3+Onggev0zLiDANC23mWrTsUgect/7
62TYg8g1bKwLAb9wCBT+BiOuCc2wrArRLOJgUkj/F4/gtrR9ima34SvWUyoUaKA0
bi4YBX9l8oJwFGHbU9uFGEMnH0T/V0KtIB7qetReywkCgYEA9cFyfBIQrYISV/OA
+Z0bo3vh2aL0QgKrSXZ924cLt7itQAHNZ2ya+e3JRlTczi5mnWfjPWZ6eJB/8MlH
Gpn12o/POEkU+XjZZSPe1RWGt5g0S3lWqyx9toCS9ACXcN9tGbaqcFSVI73zVTRA
8J9grR0fbGn7jaTlTX2tnlOTQ60CgYEA5YjYpEq4L8UUMFkuj+BsS3u0oEBnzuHd
I9LEHmN+CMPosvabQu5wkJXLuqo2TxRnAznsA8R3pCLkdPGoWMCiWRAsCn979TdY
QbqO2qvBAD2Q19GtY7lIu6C35/enQWzJUMQE3WW0OvjLzZ0l/9mA2FBRR+3F9A1d
rBdnmv0c3TcCgYEAi2i+ggVZcqPbtgrLOk5WVGo9F1GqUBvlgNn30WWNTx4zIaEk
HSxtyaOLTxtq2odV7Kr3LGiKxwPpn/T+Ief+oIp92YcTn+VfJVGw4Z3BezqbR8lA
Uf/+HF5ZfpMrVXtZD4Igs3I33Duv4sCuqhEvLWTc44pHifVloozNxYfRfU0CgYBN
HXa7a6cJ1Yp829l62QlJKtx6Ymj95oAnQu5Ez2ROiZMqXRO4nucOjGUP55Orac1a
FiGm+mC/skFS0MWgW8evaHGDbWU180wheQ35hW6oKAb7myRHtr4q20ouEtQMdQIF
snV39G1iyqeeAsf7dxWElydXpRi2b68i3BIgzhzebQKBgQCdUQuTsqV9y/JFpu6H
c5TVvhG/ubfBspI5DhQqIGijnVBzFT//UfIYMSKJo75qqBEyP2EJSmCsunWsAFsM
TszuiGTkrKcZy9G0wJqPztZZl2F2+bJgnA6nBEV7g5PA4Af+QSmaIhRwqGDAuROR
47jndeyIaMTNETEmOnms+as17g==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3FlqJr5TRskIQIgdE3Dd
7D9lboWdcTUT8a+fJR7MAvQm7XXNoYkm3v7MQL1NYtDvL2l8CAnc0WdSTINU6IRv
c5Kqo2Q4csNX9SHOmEfzoROjQqahEcve1jBXluoCXdYuYpx4/1tfRgG6ii4Uhxh6
iI8qNMJQX+fLfqhbfYfxBQVRPywBkAbIP4x1EAsbC6FSNmkhCxiMNqEgxaIpY8C2
kJdJ/ZIV+WW4noDdzpKqHcwmB8FsrumlVY/DNVvUSDIipiq9PbP4H99TXN1o746o
RaNa07rq1hoCgMSSy+85SagCoxlmyE+D+of9SsMY8Ol9t0rdzpobBuhyJ/o5dfvj
KwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,57 @@
package com.example.greeting;
import java.net.URI;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.graphql.test.tester.WebSocketGraphQlTester;
import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GreetingApplicationTests {
@LocalServerPort
private int port;
@Value("http://localhost:${local.server.port}${spring.graphql.websocket.path}")
private String baseUrl;
private GraphQlTester graphQlTester;
@BeforeEach
void setUp() {
URI url = URI.create(baseUrl);
this.graphQlTester = WebSocketGraphQlTester.builder(url, new ReactorNettyWebSocketClient())
.interceptor(JwtGraphQlClientInterceptor.create())
.build();
}
@Test
void greeting() {
this.graphQlTester.document("{greeting}")
.execute()
.path("greeting")
.entity(String.class).isEqualTo("Hello Markey!");
}
@Test
void greetings() {
Flux<String> flux = this.graphQlTester.document("subscription {greetings}")
.executeSubscription()
.toFlux("greetings", String.class);
StepVerifier.create(flux)
.expectNext("Hello Markey0!", "Hello Markey1!", "Hello Markey2!", "Hello Markey3!")
.thenCancel()
.verify();
}
}