diff --git a/gradle.properties b/gradle.properties index 2bb3fda64..2cc83a79d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,9 @@ -projectVersion=2.3.1-SNAPSHOT +projectVersion=2.4.0-SNAPSHOT micronautDocsVersion=1.0.24 micronautBuildVersion=1.1.5 -micronautVersion=2.3.1 +micronautDiscoveryClientVersion=2.2.4 +micronautSecurityVersion=2.3.0 +micronautVersion=2.3.2 micronautTestVersion=2.2.1 groovyVersion=3.0.4 spockVersion=2.0-M3-groovy-3.0 diff --git a/grpc-client-runtime/build.gradle b/grpc-client-runtime/build.gradle index 7be203990..7ea6e4d1c 100644 --- a/grpc-client-runtime/build.gradle +++ b/grpc-client-runtime/build.gradle @@ -12,11 +12,11 @@ dependencies { api "io.grpc:grpc-protobuf:$grpcVersion" api "io.grpc:grpc-stub:$grpcVersion" implementation "io.grpc:grpc-netty:$grpcVersion" - compileOnly "io.micronaut.discovery:micronaut-discovery-client:2.2.4" + compileOnly "io.micronaut.discovery:micronaut-discovery-client:$micronautDiscoveryClientVersion" compileOnly "io.micronaut:micronaut-tracing:$micronautVersion" compileOnly 'io.opentracing.contrib:opentracing-grpc:0.2.3' - testImplementation "io.micronaut.discovery:micronaut-discovery-client:2.2.4" + testImplementation "io.micronaut.discovery:micronaut-discovery-client:$micronautDiscoveryClientVersion" testImplementation 'io.opentracing:opentracing-mock:0.33.0' testImplementation "io.micronaut:micronaut-tracing:$micronautVersion" testImplementation 'io.opentracing.contrib:opentracing-grpc:0.2.3' diff --git a/grpc-server-runtime/build.gradle b/grpc-server-runtime/build.gradle index 470ddc3b5..23587241a 100644 --- a/grpc-server-runtime/build.gradle +++ b/grpc-server-runtime/build.gradle @@ -12,12 +12,12 @@ dependencies { api "io.grpc:grpc-protobuf:$grpcVersion" api "io.grpc:grpc-stub:$grpcVersion" - compileOnly "io.micronaut.discovery:micronaut-discovery-client:2.2.4" + compileOnly "io.micronaut.discovery:micronaut-discovery-client:$micronautDiscoveryClientVersion" compileOnly "io.micronaut:micronaut-tracing:$micronautVersion" compileOnly "io.micronaut:micronaut-management" compileOnly 'io.opentracing.contrib:opentracing-grpc:0.2.3' - testImplementation "io.micronaut.discovery:micronaut-discovery-client:2.2.4" + testImplementation "io.micronaut.discovery:micronaut-discovery-client:$micronautDiscoveryClientVersion" testImplementation 'io.opentracing:opentracing-mock:0.33.0' testImplementation "io.micronaut:micronaut-tracing:$micronautVersion" testImplementation 'io.opentracing.contrib:opentracing-grpc:0.2.3' diff --git a/grpc-server-security-jwt/build.gradle b/grpc-server-security-jwt/build.gradle new file mode 100644 index 000000000..3ca070937 --- /dev/null +++ b/grpc-server-security-jwt/build.gradle @@ -0,0 +1,56 @@ + +plugins { + id "net.ltgt.apt" version "0.21" + id 'com.google.protobuf' version '0.8.15' +} + +dependencies { + + annotationProcessor "io.micronaut:micronaut-inject-java:$micronautVersion" + testAnnotationProcessor "io.micronaut.security:micronaut-security-annotations:$micronautSecurityVersion" + + api project(":grpc-server-runtime") + api project(":grpc-server-security") + api "io.micronaut:micronaut-inject:$micronautVersion" + api "io.micronaut:micronaut-runtime:$micronautVersion" + api "com.nimbusds:nimbus-jose-jwt:9.4.2" + + implementation("io.micronaut.security:micronaut-security-jwt:$micronautSecurityVersion") { + exclude group: 'io.micronaut', module: 'micronaut-http' + exclude group: 'io.micronaut', module: 'micronaut-http-server' + } + + testImplementation "io.micronaut:micronaut-inject-groovy:$micronautVersion" + testImplementation "io.micronaut:micronaut-inject-java:$micronautVersion" + testImplementation "io.micronaut.test:micronaut-test-spock:$micronautTestVersion" + +} + +protobuf { + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } + plugins { + grpc { artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" } + } + generateProtoTasks { + all()*.plugins { grpc {} } + } +} + +sourceSets { + test { + java { + srcDirs 'build/generated/source/proto/test/grpc' + srcDirs 'build/generated/source/proto/test/java' + } + } +} + +protobuf { + protoc { artifact = "com.google.protobuf:protoc:$protocVersion" } + plugins { + grpc { artifact = "io.grpc:protoc-gen-grpc-java:1.35.0" } + } + generateProtoTasks { + all()*.plugins { grpc {} } + } +} \ No newline at end of file diff --git a/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfiguration.java b/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfiguration.java new file mode 100644 index 000000000..3793341b3 --- /dev/null +++ b/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security.jwt; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.bind.annotation.Bindable; +import io.micronaut.core.util.StringUtils; +import io.micronaut.core.util.Toggleable; +import io.micronaut.grpc.server.security.GrpcServerSecurityConfiguration; + +import javax.validation.constraints.NotBlank; + +/** + * gRPC Security JWT configuration. + * + * @since 2.4.0 + * @author Brian Wyka + */ +@ConfigurationProperties(GrpcServerSecurityJwtConfiguration.PREFIX) +@Requires(configuration = "io.micronaut.security") +@Requires(configuration = "io.micronaut.security.token.jwt") +@Requires(configuration = "io.micronaut.grpc.server.security") +@Requires(property = GrpcServerSecurityJwtConfiguration.PREFIX + ".enabled", notEquals = StringUtils.FALSE) +public interface GrpcServerSecurityJwtConfiguration extends Toggleable { + + /** + * The configuration prefix. + */ + String PREFIX = GrpcServerSecurityConfiguration.PREFIX + ".token.jwt"; + + /** + * The default name for the JWT metadata key. + */ + String DEFAULT_METADATA_KEY_NAME = "JWT"; + + /** + * The name of the metadata key which holds the JWT. Defaults + * to {@value #DEFAULT_METADATA_KEY_NAME} if not configured. + * + * @return the metadata key name + */ + @NotBlank + @Bindable(defaultValue = DEFAULT_METADATA_KEY_NAME) + String getMetadataKeyName(); + +} diff --git a/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcher.java b/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcher.java new file mode 100644 index 000000000..87bccbf94 --- /dev/null +++ b/grpc-server-security-jwt/src/main/java/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcher.java @@ -0,0 +1,60 @@ +package io.micronaut.grpc.server.security.jwt; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.micronaut.grpc.server.security.GrpcServerAuthenticationFetcher; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.jwt.validator.JwtAuthenticationFactory; +import io.micronaut.security.token.jwt.validator.JwtValidator; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Optional; + +/** + * Fetches {@link Authentication} from JWT present in the gRPC {@link Metadata}. + * + * @since 2.4.0 + * @author Brian Wyka + */ +@Singleton +public class JwtGrpcServerAuthenticationFetcher implements GrpcServerAuthenticationFetcher { + + private final Metadata.Key jwtMetadataKey; + private final JwtValidator jwtValidator; + private final JwtAuthenticationFactory jwtAuthenticationFactory; + + /** + * Constructs the authentication fetcher with the provided configuration and JWT validator. + * + * @param config the configuration + * @param jwtValidator the JWT validator + * @param jwtAuthenticationFactory the JWT authentication factory + */ + @Inject + public JwtGrpcServerAuthenticationFetcher(final GrpcServerSecurityJwtConfiguration config, + final JwtValidator jwtValidator, + final JwtAuthenticationFactory jwtAuthenticationFactory) { + this.jwtMetadataKey = Metadata.Key.of(config.getMetadataKeyName(), Metadata.ASCII_STRING_MARSHALLER); + this.jwtValidator = jwtValidator; + this.jwtAuthenticationFactory = jwtAuthenticationFactory; + } + + /** + * Fetch the {@link Authentication} from JWT in metadata. + * + * @param serverCall {@link ServerCall} being executed. + * @param metadata the metadata to retrieve JWT from + * @param the type of the server call request + * @param the type of the server call response + * @return the authentication if found, otherwise {@link Optional#empty()} + */ + @Override + public Optional fetchAuthentication(final ServerCall serverCall, final Metadata metadata) { + return Optional.of(jwtMetadataKey) + .map(metadata::get) + .flatMap(jwt -> jwtValidator.validate(jwt, null)) + .flatMap(jwtAuthenticationFactory::createAuthentication); + } + +} diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationDisabledSpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationDisabledSpec.groovy new file mode 100644 index 000000000..e1a9667ed --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationDisabledSpec.groovy @@ -0,0 +1,39 @@ +package io.micronaut.grpc.server.security.jwt + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.exceptions.NoSuchBeanException +import io.micronaut.grpc.server.security.jwt.interceptor.GrpcServerSecurityJwtInterceptor +import spock.lang.Specification + +class GrpcServerSecurityJwtConfigurationDisabledSpec extends Specification { + + def "beans are not loaded when grpc security jwt not enabled"() { + given: + def config = [ + "grpc.server.security.token.jwt.enabled": false + ] + def context = ApplicationContext.run(config) + + when: + context.getBean(GrpcServerSecurityJwtConfiguration) + + then: + thrown(NoSuchBeanException) + + when: + context.getBean(GrpcServerSecurityJwtInterceptorFactory) + + then: + thrown(NoSuchBeanException) + + when: + context.getBean(GrpcServerSecurityJwtInterceptor) + + then: + thrown(NoSuchBeanException) + + cleanup: + context.close() + } + +} \ No newline at end of file diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationEnabledSpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationEnabledSpec.groovy new file mode 100644 index 000000000..20871a959 --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationEnabledSpec.groovy @@ -0,0 +1,38 @@ +package io.micronaut.grpc.server.security.jwt + + +import io.grpc.ServerInterceptor +import io.grpc.Status +import io.micronaut.context.annotation.Property +import io.micronaut.core.order.Ordered +import io.micronaut.grpc.server.security.jwt.interceptor.GrpcServerSecurityJwtInterceptor +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import spock.lang.Specification + +import javax.inject.Inject + +@MicronautTest +@Property(name = "grpc.server.security.token.jwt.enabled", value = "true") +class GrpcServerSecurityJwtConfigurationEnabledSpec extends Specification { + + @Inject + List serverInterceptors + + @Inject + GrpcServerSecurityJwtConfiguration config + + def "server interceptor bean present"() { + expect: + serverInterceptors.find { it instanceof GrpcServerSecurityJwtInterceptor } + } + + def "GRPC server security JWT configuration defaults"() { + expect: + config.enabled + config.metadataKeyName == "JWT" + config.missingTokenStatus == Status.UNAUTHENTICATED.code + config.failedValidationTokenStatus == Status.PERMISSION_DENIED.code + config.interceptorOrder == Ordered.HIGHEST_PRECEDENCE + } + +} diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationOverrideSpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationOverrideSpec.groovy new file mode 100644 index 000000000..048656761 --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtConfigurationOverrideSpec.groovy @@ -0,0 +1,40 @@ +package io.micronaut.grpc.server.security.jwt + +import io.grpc.ServerInterceptor +import io.grpc.Status +import io.micronaut.context.annotation.Property +import io.micronaut.grpc.server.security.jwt.interceptor.GrpcServerSecurityJwtInterceptor +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import spock.lang.Specification + +import javax.inject.Inject + +@MicronautTest +@Property(name = "grpc.server.security.token.jwt.enabled", value = "true") +@Property(name = "grpc.server.security.token.jwt.metadata-key-name", value = "AUTH") +@Property(name = "grpc.server.security.token.jwt.missing-token-status", value = "NOT_FOUND") +@Property(name = "grpc.server.security.token.jwt.failed-validation-token-status", value = "ABORTED") +@Property(name = "grpc.server.security.token.jwt.interceptor-order", value = "100") +class GrpcServerSecurityJwtConfigurationOverrideSpec extends Specification { + + @Inject + List serverInterceptors + + @Inject + GrpcServerSecurityJwtConfiguration config + + def "server interceptor bean present"() { + expect: + serverInterceptors.find { it instanceof GrpcServerSecurityJwtInterceptor } + } + + def "GRPC server security JWT configuration defaults override"() { + expect: + config.enabled + config.metadataKeyName == "AUTH" + config.missingTokenStatus == Status.NOT_FOUND.code + config.failedValidationTokenStatus == Status.ABORTED.code + config.interceptorOrder == 100 + } + +} diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtInterceptorFactorySpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtInterceptorFactorySpec.groovy new file mode 100644 index 000000000..ea0274964 --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/GrpcServerSecurityJwtInterceptorFactorySpec.groovy @@ -0,0 +1,26 @@ +package io.micronaut.grpc.server.security.jwt + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Property +import io.micronaut.grpc.server.security.jwt.interceptor.GrpcServerSecurityJwtInterceptor +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import spock.lang.Specification + +import javax.inject.Inject + +@MicronautTest +@Property(name = "grpc.server.security.token.jwt.enabled", value = "true") +@Property(name = "micronaut.security.token.jwt.signatures.secret.generator.secret", value = "SeCr3t") +@Property(name = "micronaut.security.token.jwt.signatures.secret.generator.base64", value = "false") +@Property(name = "micronaut.security.token.jwt.signatures.secret.generator.jws-algorithm", value = "HS512") +class GrpcServerSecurityJwtInterceptorFactorySpec extends Specification { + + @Inject + ApplicationContext applicationContext + + def "serverInterceptor bean present"() { + expect: + applicationContext.getBean(GrpcServerSecurityJwtInterceptor) + } + +} diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcherSpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcherSpec.groovy new file mode 100644 index 000000000..914a2d3da --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/JwtGrpcServerAuthenticationFetcherSpec.groovy @@ -0,0 +1,9 @@ +package io.micronaut.grpc.server.security.jwt + +import spock.lang.Specification + +class JwtGrpcServerAuthenticationFetcherSpec extends Specification { + + // TODO + +} diff --git a/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/interceptor/GrpcServerSecurityJwtInterceptorSpec.groovy b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/interceptor/GrpcServerSecurityJwtInterceptorSpec.groovy new file mode 100644 index 000000000..e23e351ee --- /dev/null +++ b/grpc-server-security-jwt/src/test/groovy/io/micronaut/grpc/server/security/jwt/interceptor/GrpcServerSecurityJwtInterceptorSpec.groovy @@ -0,0 +1,486 @@ +package io.micronaut.grpc.server.security.jwt.interceptor + +import io.grpc.Channel +import io.grpc.Metadata +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.grpc.examples.helloworld.GreeterGrpc +import io.grpc.examples.helloworld.HelloReply +import io.grpc.examples.helloworld.HelloRequest +import io.grpc.stub.MetadataUtils +import io.grpc.stub.StreamObserver +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Requires +import io.micronaut.context.env.Environment +import io.micronaut.grpc.annotation.GrpcChannel +import io.micronaut.grpc.server.GrpcEmbeddedServer +import io.micronaut.grpc.server.GrpcServerChannel +import io.micronaut.security.authentication.UserDetails +import io.micronaut.security.token.jwt.generator.JwtTokenGenerator +import spock.lang.Specification +import spock.lang.Unroll + +import javax.inject.Inject +import javax.inject.Singleton + +class GrpcServerSecurityJwtInterceptorSpec extends Specification { + + private static final REQUIRED_ENV = "greeter-hello-world-jwt" + private static final Map defaultConfigurations = [ + "grpc.server.security.token.jwt.enabled": true, + "micronaut.security.token.jwt.signatures.secret.generator.secret": "SeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3tSeCr3t", + "micronaut.security.token.jwt.signatures.secret.generator.base64": false, + "micronaut.security.token.jwt.signatures.secret.generator.jws-algorithm": "HS512" + ] + + def "test order configuration respected"() { + given: + Map config = new HashMap<>(defaultConfigurations) + config.put("grpc.server.security.token.jwt.interceptor-order", 100) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def interceptor = context.getBean(GrpcServerSecurityJwtInterceptor) + + then: + interceptor.order == 100 + + cleanup: + embeddedServer.close() + context.stop() + } + + @Unroll + def "test valid JWT works when required role = isAuthenticated() - pattern matches"(String pattern) { + given: + List interceptMethodPatterns = [ + [pattern: pattern, access: ["isAuthenticated()"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithJwt("Brian") == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + + where: + pattern << ["helloworld.Greeter/SayHello", "helloworld.Greeter/.*", "helloworld.*"] + } + + def "test valid JWT works when required role = isAuthenticated() and custom metadata key name"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["isAuthenticated()"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("grpc.server.security.token.jwt.metadata-key-name", "AUTH") + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithCustomJwt("AUTH", "Brian") == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT works when ROLE_HELLO required"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["ROLE_HELLO"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT works with no roles configured and reject-not-found = false"() { + given: + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.reject-not-found", false) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + } + + @Unroll + def "test valid JWT works with no roles found matching pattern and reject-not-found = false"(String pattern) { + given: + List interceptMethodPatterns = [ + [pattern: pattern, access: ["ROLE_HELLO"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + config.put("micronaut.security.reject-not-found", false) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + + where: + pattern << ["helloworld.Greeter/SayGoodbye", "helloworld.Greeter/Talk.*", "helloearth.*"] + } + + def "test valid JWT works with no matching roles configured and reject-not-found = false"() { + given: + List interceptMethodPatterns = [ + [pattern: "example.Foo/get.*", access: ["ROLE_HELLO"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + config.put("micronaut.security.reject-not-found", false) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithJwt("Brian") == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT denied when required roles = denyAll()"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["ROLE_HELLO", "denyAll()"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.PERMISSION_DENIED + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT denied when missing required role = ROLE_HELLO"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["ROLE_HELLO"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithJwt("Brian", ["ROLE_OTHER"]) == "Hello Brian" + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.PERMISSION_DENIED + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT denied when required roles = denyAll() - custom status"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["ROLE_HELLO", "denyAll()"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("grpc.server.security.token.jwt.failed-validation-token-status", "ABORTED") + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.ABORTED + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT denied when no roles configured"() { + given: + Map config = new HashMap<>(defaultConfigurations) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithJwt("Brian", ["ROLE_HELLO"]) + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.PERMISSION_DENIED + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test valid JWT denied when no subject / claims present"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["ROLE_HELLO", "isAuthenticated()"]] + ] + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithJwtNoSubject("Brian") + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.PERMISSION_DENIED + + cleanup: + embeddedServer.close() + context.stop() + } + + def "test missing JWT works when role = isAnonymous()"() { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: ["isAnonymous()"]] + ]; + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + + when: + def testBean = context.getBean(TestBean) + + then: + testBean.sayHelloWithoutJwt("Brian") == "Hello Brian" + + cleanup: + embeddedServer.close() + context.stop() + } + + @Unroll + def "test missing JWT denied when role = #role"(String role) { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: [role]] + ]; + Map config = new HashMap<>(defaultConfigurations) + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithoutJwt("Brian") + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.UNAUTHENTICATED.code + + cleanup: + embeddedServer.close() + context.stop() + + where: + role << ["isAuthenticated()", "ROLE_HELLO"] + } + + @Unroll + def "test missing JWT denied when role = #role - custom status"(String role) { + given: + List interceptMethodPatterns = [ + [pattern: ".*", access: [role]] + ]; + Map config = new HashMap<>(defaultConfigurations) + config.put("grpc.server.security.token.jwt.missing-token-status", "NOT_FOUND") + config.put("micronaut.security.intercept-url-map", interceptMethodPatterns) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithoutJwt("Brian") + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.NOT_FOUND.code + + cleanup: + embeddedServer.close() + context.stop() + + where: + role << ["isAuthenticated()", "ROLE_HELLO"] + } + + def "test invalid JWT denied"() { + given: + Map config = new HashMap<>(defaultConfigurations) + def context = ApplicationContext.run(config, REQUIRED_ENV, Environment.TEST) + def embeddedServer = context.getBean(GrpcEmbeddedServer) + embeddedServer.start() + def testBean = context.getBean(TestBean) + + when: + testBean.sayHelloWithNonParseableJwt("Brian") + + then: + StatusRuntimeException statusRuntimeException = thrown(StatusRuntimeException) + statusRuntimeException.status.code == Status.Code.PERMISSION_DENIED + + cleanup: + embeddedServer.close() + context.stop() + } + + @Factory + @Requires(env = REQUIRED_ENV) + static class Clients { + + @Singleton + GreeterGrpc.GreeterBlockingStub blockingStub(@GrpcChannel(GrpcServerChannel.NAME) Channel channel) { + GreeterGrpc.newBlockingStub(channel) + } + + } + + @Singleton + @Requires(env = REQUIRED_ENV) + static class TestBean { + + @Inject + JwtTokenGenerator jwtTokenGenerator + + @Inject + GreeterGrpc.GreeterBlockingStub blockingStub + + String sayHelloWithJwt(String message, final List roles = []) { + UserDetails userDetails = new UserDetails("micronaut", roles) + String jwt = jwtTokenGenerator.generateToken(userDetails, 60).get() + Metadata metadata = new Metadata() + metadata.put(Metadata.Key.of("JWT", Metadata.ASCII_STRING_MARSHALLER), jwt) + HelloRequest helloRequest = HelloRequest.newBuilder().setName(message).build() + MetadataUtils.attachHeaders(blockingStub, metadata).sayHello(helloRequest).message + } + + String sayHelloWithCustomJwt(String metadataKeyName, String message, final List roles = []) { + UserDetails userDetails = new UserDetails("micronaut", roles) + String jwt = jwtTokenGenerator.generateToken(userDetails, 60).get() + Metadata metadata = new Metadata() + metadata.put(Metadata.Key.of(metadataKeyName, Metadata.ASCII_STRING_MARSHALLER), jwt) + HelloRequest helloRequest = HelloRequest.newBuilder().setName(message).build() + MetadataUtils.attachHeaders(blockingStub, metadata).sayHello(helloRequest).message + } + + String sayHelloWithJwtNoSubject(String message) { + String jwt = jwtTokenGenerator.generateToken([:]).get() + Metadata metadata = new Metadata() + metadata.put(Metadata.Key.of("JWT", Metadata.ASCII_STRING_MARSHALLER), jwt) + HelloRequest helloRequest = HelloRequest.newBuilder().setName(message).build() + MetadataUtils.attachHeaders(blockingStub, metadata).sayHello(helloRequest).message + } + + String sayHelloWithNonParseableJwt(String message) { + Metadata metadata = new Metadata() + metadata.put(Metadata.Key.of("JWT", Metadata.ASCII_STRING_MARSHALLER), "invalid-jwt-which-cannot-be-parsed") + HelloRequest helloRequest = HelloRequest.newBuilder().setName(message).build() + MetadataUtils.attachHeaders(blockingStub, metadata).sayHello(helloRequest).message + } + + String sayHelloWithoutJwt(String message) { + HelloRequest helloRequest = HelloRequest.newBuilder().setName(message).build() + blockingStub.sayHello(helloRequest).message + } + + } + + @Singleton + @Requires(env = REQUIRED_ENV) + static class GreeterImpl extends GreeterGrpc.GreeterImplBase { + + @Override + void sayHello(HelloRequest request, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + request.getName()).build(); + responseObserver.onNext(reply) + responseObserver.onCompleted() + } + + } + +} \ No newline at end of file diff --git a/grpc-server-security-jwt/src/test/proto/helloworld.proto b/grpc-server-security-jwt/src/test/proto/helloworld.proto new file mode 100644 index 000000000..38a3d17fe --- /dev/null +++ b/grpc-server-security-jwt/src/test/proto/helloworld.proto @@ -0,0 +1,43 @@ +// Copyright 2015 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; +option objc_class_prefix = "HLW"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The greeting service definition. +service Greeter2 { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} \ No newline at end of file diff --git a/grpc-server-security-jwt/src/test/resources/logback.xml b/grpc-server-security-jwt/src/test/resources/logback.xml new file mode 100644 index 000000000..5aa46da93 --- /dev/null +++ b/grpc-server-security-jwt/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/grpc-server-security/build.gradle b/grpc-server-security/build.gradle new file mode 100644 index 000000000..1afa41f9d --- /dev/null +++ b/grpc-server-security/build.gradle @@ -0,0 +1,19 @@ + +dependencies { + + annotationProcessor "io.micronaut:micronaut-inject-java:$micronautVersion" + + api project(":grpc-server-runtime") + api "io.micronaut:micronaut-inject:$micronautVersion" + api "io.micronaut:micronaut-runtime:$micronautVersion" + + implementation("io.micronaut.security:micronaut-security:$micronautSecurityVersion") { + exclude group: 'io.micronaut', module: 'micronaut-http' + exclude group: 'io.micronaut', module: 'micronaut-http-server' + } + + testImplementation "io.micronaut:micronaut-inject-groovy:$micronautVersion" + testImplementation "io.micronaut:micronaut-inject-java:$micronautVersion" + testImplementation "io.micronaut.test:micronaut-test-spock:$micronautTestVersion" + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRule.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRule.java new file mode 100644 index 000000000..1cf33cbda --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRule.java @@ -0,0 +1,95 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import edu.umd.cs.findbugs.annotations.Nullable; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.security.rules.SecurityRuleResult; +import io.micronaut.security.token.MapClaims; +import io.micronaut.security.token.RolesFinder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Abstract gRPC server security rule. + * + * @since 2.4.0 + * @author Brian Wyka + */ +public abstract class AbstractGrpcServerSecurityRule implements GrpcServerSecurityRule { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractGrpcServerSecurityRule.class); + protected final RolesFinder rolesFinder; + + /** + * Constructs an instance of this rule with the provided configuration and roles finder. + * + * @param rolesFinder the roles finder + */ + protected AbstractGrpcServerSecurityRule(final RolesFinder rolesFinder) { + this.rolesFinder = rolesFinder; + } + + /** + * Appends {@link SecurityRule#IS_ANONYMOUS} if not authenticated. If the + * claims contain one or more roles, {@link SecurityRule#IS_AUTHENTICATED} is + * appended to the list. + * + * @param claims The claims of the token, null if not authenticated + * @return The granted roles + */ + protected List getRoles(@Nullable final Map claims) { + List roles = new ArrayList<>(); + if (claims == null) { + roles.add(SecurityRule.IS_ANONYMOUS); + } else { + if (!claims.isEmpty()) { + roles.addAll(rolesFinder.findInClaims(new MapClaims(claims))); + } + roles.add(SecurityRule.IS_ANONYMOUS); + roles.add(SecurityRule.IS_AUTHENTICATED); + } + return roles; + } + + /** + * Compares the given roles to determine if the request is allowed by + * comparing if any of the granted roles is in the required roles list. + * + * @param requiredRoles The list of roles required to be authorized + * @param grantedRoles The list of roles granted to the user + * @return {@link SecurityRuleResult#REJECTED} if none of the granted roles + * appears in the required roles list. {@link SecurityRuleResult#ALLOWED} otherwise. + */ + protected SecurityRuleResult compareRoles(final List requiredRoles, final List grantedRoles) { + if (rolesFinder.hasAnyRequiredRoles(requiredRoles, grantedRoles)) { + if (LOG.isDebugEnabled()) { + LOG.debug("The given roles [{}] matched one or more of the required roles [{}]. Allowing the request", grantedRoles, requiredRoles); + } + return SecurityRuleResult.ALLOWED; + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("None of the given roles [{}] matched the required roles [{}]. Rejecting the request", grantedRoles, requiredRoles); + } + return SecurityRuleResult.REJECTED; + } + } + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerAuthenticationFetcher.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerAuthenticationFetcher.java new file mode 100644 index 000000000..efc3c9216 --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerAuthenticationFetcher.java @@ -0,0 +1,41 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.micronaut.core.order.Ordered; +import io.micronaut.security.authentication.Authentication; + +import java.util.Optional; + +/** + * gRPC Authentication Fetcher. + * + * @since 2.4.0 + * @author Brian Wyka + */ +public interface GrpcServerAuthenticationFetcher extends Ordered { + + /** + * Attempts to read an {@link Authentication} from a {@link ServerCall} being executed. + * + * @param serverCall {@link ServerCall} being executed. + * @return {@link Authentication} if found + */ + Optional fetchAuthentication(final ServerCall serverCall, final Metadata metadata); + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityConfiguration.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityConfiguration.java new file mode 100644 index 000000000..b2c70b6a4 --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityConfiguration.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import io.grpc.Status; +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.bind.annotation.Bindable; +import io.micronaut.core.util.Toggleable; +import io.micronaut.grpc.server.GrpcServerConfiguration; + +/** + * gRPC Server Security Configuration. + * + * @since 2.4.0 + * @author Brian Wyka + */ +@Requires(configuration = "io.micronaut.security") +@ConfigurationProperties(GrpcServerSecurityConfiguration.PREFIX) +public interface GrpcServerSecurityConfiguration extends Toggleable { + + String PREFIX = GrpcServerConfiguration.PREFIX + ".security"; + + /** + * The {@link Status} returned by the interceptor when there is no {@link io.micronaut.security.authentication.Authentication} present. + * The default value is {@link Status.Code#UNAUTHENTICATED} + * + * @return the status + */ + @Bindable(defaultValue = "UNAUTHENTICATED") + Status.Code getMissingAuthenticationStatus(); + + /** + * The {@link Status} returned by the interceptor when authorization fails. The + * default value is {@link Status.Code#PERMISSION_DENIED} + * + * @return the status + */ + @Bindable(defaultValue = "PERMISSION_DENIED") + Status.Code getFailedAuthorizationStatus(); + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptor.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptor.java new file mode 100644 index 000000000..e35b81a2f --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptor.java @@ -0,0 +1,186 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import io.micronaut.context.annotation.Requires; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.config.SecurityConfiguration; +import io.micronaut.security.rules.SecurityRuleResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +/** + * gRPC Server Security Interceptor responsible for checking {@link GrpcServerSecurityRule} + * results prior to allowing access to gRPC server methods. + * + * @since 2.4.0 + * @author Brian Wyka + */ +@Singleton +@Requires(beans = GrpcServerSecurityConfiguration.class) +public class GrpcServerSecurityInterceptor implements ServerInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(GrpcServerSecurityInterceptor.class); + private final SecurityConfiguration securityConfiguration; + private final GrpcServerSecurityConfiguration grpcServerSecurityConfiguration; + private final Collection grpcServerAuthenticationFetchers; + private final Collection grpcServerSecurityRules; + + /** + * Constructs an instance of the security interceptor with the provided configurations and rules. + * @param securityConfiguration the security configuration + * @param grpcServerSecurityConfiguration the gRPC security configuration + * @param grpcServerAuthenticationFetchers the gRPC server authentication fetchers + * @param grpcServerSecurityRules the method security rules + */ + @Inject + public GrpcServerSecurityInterceptor(final SecurityConfiguration securityConfiguration, + final GrpcServerSecurityConfiguration grpcServerSecurityConfiguration, + final Collection grpcServerAuthenticationFetchers, + final Collection grpcServerSecurityRules) { + this.securityConfiguration = securityConfiguration; + this.grpcServerSecurityConfiguration = grpcServerSecurityConfiguration; + this.grpcServerAuthenticationFetchers = grpcServerAuthenticationFetchers; + this.grpcServerSecurityRules = grpcServerSecurityRules; + } + + /** + * Intercept the gRPC server call and check security rules. If no {@link Authentication} is found, the server call + * is forwarded onto the next server call handler. + * + * @param serverCall the server call + * @param metadata the metadata + * @param next the next server call handler + * @param the type of the request + * @param the type of the response + * @return the server call listener + */ + @Override + public ServerCall.Listener interceptCall(final ServerCall serverCall, final Metadata metadata, final ServerCallHandler next) { + final String fullMethodName = serverCall.getMethodDescriptor().getFullMethodName(); + final Optional> authentication = grpcServerAuthenticationFetchers.stream() + .map(grpcServerAuthenticationFetcher -> grpcServerAuthenticationFetcher.fetchAuthentication(serverCall, metadata)) + .findFirst(); + if (authentication.isPresent()) { + return checkRules(serverCall, metadata, next, fullMethodName, authentication.get().orElse(null)); + } + if (LOG.isDebugEnabled()) { + LOG.debug("No Authentication fetched for server call. {}.", fullMethodName); + } + return forward(serverCall, metadata, next); + } + + /** + * Check the rules to see if the server call should be allowed or rejected. + * + * @param serverCall the server call + * @param metadata the metadata + * @param next the next server call handler + * @param fullMethodName the full method name + * @param authentication the authentication (may be null) + * @param the type of the server call request + * @param the type of the server call response + * @return the server call listener + */ + private ServerCall.Listener checkRules(final ServerCall serverCall, final Metadata metadata, final ServerCallHandler next, + final String fullMethodName, @Nullable final Authentication authentication) { + final boolean forbidden = authentication != null; + final Optional> listenerOptional = grpcServerSecurityRules.stream() + .map(grpcServerSecurityRule -> checkRule(serverCall, metadata, next, authentication, fullMethodName, forbidden, grpcServerSecurityRule)) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + if (listenerOptional.isPresent()) { + return listenerOptional.get(); + } + if (LOG.isDebugEnabled()) { + LOG.debug("Authorized server call to {}. No rule provider authorized or rejected the call.", fullMethodName); + } + if (securityConfiguration.isRejectNotFound() && forbidden) { + throw Status.fromCode(grpcServerSecurityConfiguration.getFailedAuthorizationStatus()).asRuntimeException(); + } else if (securityConfiguration.isRejectNotFound()) { + throw Status.fromCode(grpcServerSecurityConfiguration.getMissingAuthenticationStatus()).asRuntimeException(); + } + return forward(serverCall, metadata, next); + } + + /** + * Check the rules to see if the server call should be allowed or rejected. + * + * @param serverCall the server call + * @param metadata the metadata + * @param next the next server call handler + * @param authentication the authentication + * @param fullMethodName the full method name + * @param forbidden true if there is no authentication + * @param grpcServerSecurityRule the method security rule to check + * @param the type of the server call request + * @param the type of the server call response + * @return the server call listener + */ + private Optional> checkRule(final ServerCall serverCall, final Metadata metadata, final ServerCallHandler next, + @Nullable final Authentication authentication, final String fullMethodName, + final boolean forbidden, final GrpcServerSecurityRule grpcServerSecurityRule) { + final Map claims = Optional.ofNullable(authentication) + .map(Authentication::getAttributes) + .orElse(null); + final SecurityRuleResult result = grpcServerSecurityRule.check(serverCall, metadata, claims); + if (result == SecurityRuleResult.REJECTED) { + if (LOG.isDebugEnabled()) { + LOG.debug("Unauthorized server call to {}. The rule provider {} rejected the call.", fullMethodName, grpcServerSecurityRule.getClass().getName()); + } + if (forbidden) { + throw Status.fromCode(grpcServerSecurityConfiguration.getFailedAuthorizationStatus()).asRuntimeException(); + } + throw Status.fromCode(grpcServerSecurityConfiguration.getMissingAuthenticationStatus()).asRuntimeException(); + } + if (result == SecurityRuleResult.ALLOWED) { + if (LOG.isDebugEnabled()) { + LOG.debug("Authorized server call to {}. The rule provider {} authorized the request.", fullMethodName, grpcServerSecurityRule.getClass().getName()); + } + return Optional.of(forward(serverCall, metadata, next)); + } + return Optional.empty(); + } + + /** + * Forward the call to next handler. + * + * @param call the server call + * @param metadata the metadata + * @param next the next handler + * @param the type of the request + * @param the type of the response + * @return the server call listener + */ + private static ServerCall.Listener forward(final ServerCall call, final Metadata metadata, final ServerCallHandler next) { + return new ForwardingServerCallListener.SimpleForwardingServerCallListener(next.startCall(call, metadata)) { }; + } + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityRule.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityRule.java new file mode 100644 index 000000000..ebefda9fa --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/GrpcServerSecurityRule.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.micronaut.core.order.Ordered; +import io.micronaut.security.rules.SecurityRuleResult; + +import java.util.Map; + +/** + * A security rule designed to offer gRPC server security. + * + * @since 2.4.0 + * @author Brian Wyka + */ +public interface GrpcServerSecurityRule extends Ordered { + + /** + * Check the server call and claims for access to the method. + * + * @param serverCall the server call + * @param metadata the metadata + * @param claims the claims + * @param the type of the server request + * @param the type of the server response + * @return the security rule result + */ + SecurityRuleResult check(final ServerCall serverCall, final Metadata metadata, @Nullable final Map claims); + +} diff --git a/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRule.java b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRule.java new file mode 100644 index 000000000..f16e5048a --- /dev/null +++ b/grpc-server-security/src/main/java/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRule.java @@ -0,0 +1,86 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.grpc.server.security; + +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.security.config.InterceptUrlMapPattern; +import io.micronaut.security.config.SecurityConfiguration; +import io.micronaut.security.rules.SecurityRuleResult; +import io.micronaut.security.token.RolesFinder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Map; +import java.util.Optional; + +/** + * gRPC Method Security Rule which uses {@link SecurityConfiguration#getInterceptUrlMap()} to check rules. + * + * @since 2.4.0 + * @author Brian Wyka + */ +@Singleton +public class InterceptUrlMapGrpcServerSecurityRule extends AbstractGrpcServerSecurityRule { + + private static final Logger LOG = LoggerFactory.getLogger(InterceptUrlMapGrpcServerSecurityRule.class); + private final SecurityConfiguration securityConfiguration; + + /** + * Constructs an instance of this rule with the provided configuration and roles finder. + * + * @param securityConfiguration the security configuration + * @param rolesFinder the roles finder + */ + @Inject + public InterceptUrlMapGrpcServerSecurityRule(final SecurityConfiguration securityConfiguration, final RolesFinder rolesFinder) { + super(rolesFinder); + this.securityConfiguration = securityConfiguration; + } + + /** + * Run the security rule check. + * + * @param serverCall the server call + * @param metadata the metadata + * @param claims the claims + * @param the type of the server call request + * @param the type of the server call response + * @return the security rule result. + */ + @Override + public SecurityRuleResult check(final ServerCall serverCall, final Metadata metadata, @Nullable final Map claims) { + if (CollectionUtils.isEmpty(securityConfiguration.getInterceptUrlMap())) { + return SecurityRuleResult.UNKNOWN; + } + final String fullMethodName = serverCall.getMethodDescriptor().getFullMethodName(); + final Optional matchedPattern = securityConfiguration.getInterceptUrlMap().stream() + .filter(interceptUrlMapPattern -> serverCall.getMethodDescriptor().getFullMethodName().matches(interceptUrlMapPattern.getPattern())) + .findFirst(); + if (matchedPattern.isPresent()) { + return compareRoles(matchedPattern.get().getAccess(), getRoles(claims)); + } + if (LOG.isDebugEnabled()) { + LOG.debug("No intercept map patterns match for method [{}]. Returning UNKNOWN.", fullMethodName); + } + return SecurityRuleResult.UNKNOWN; + } + +} diff --git a/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRuleSpec.groovy b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRuleSpec.groovy new file mode 100644 index 000000000..3389a8403 --- /dev/null +++ b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/AbstractGrpcServerSecurityRuleSpec.groovy @@ -0,0 +1,9 @@ +package io.micronaut.grpc.server.security + +import spock.lang.Specification + +class AbstractGrpcServerSecurityRuleSpec extends Specification { + + // TODO + +} diff --git a/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityConfigurationSpec.groovy b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityConfigurationSpec.groovy new file mode 100644 index 000000000..7a280c8f4 --- /dev/null +++ b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityConfigurationSpec.groovy @@ -0,0 +1,9 @@ +package io.micronaut.grpc.server.security + +import spock.lang.Specification + +class GrpcServerSecurityConfigurationSpec extends Specification { + + // TODO + +} diff --git a/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptorSpec.groovy b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptorSpec.groovy new file mode 100644 index 000000000..7cc7efc14 --- /dev/null +++ b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/GrpcServerSecurityInterceptorSpec.groovy @@ -0,0 +1,9 @@ +package io.micronaut.grpc.server.security + +import spock.lang.Specification + +class GrpcServerSecurityInterceptorSpec extends Specification { + + // TODO + +} diff --git a/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRuleSpec.groovy b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRuleSpec.groovy new file mode 100644 index 000000000..172cafa80 --- /dev/null +++ b/grpc-server-security/src/test/groovy/io/micronaut/grpc/server/security/InterceptUrlMapGrpcServerSecurityRuleSpec.groovy @@ -0,0 +1,9 @@ +package io.micronaut.grpc.server.security + +import spock.lang.Specification + +class InterceptUrlMapGrpcServerSecurityRuleSpec extends Specification { + + // TODO + +} diff --git a/grpc-server-security/src/test/resources/logback.xml b/grpc-server-security/src/test/resources/logback.xml new file mode 100644 index 000000000..aa04691ff --- /dev/null +++ b/grpc-server-security/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index a16b71ee9..a70e8a20b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,5 +3,8 @@ rootProject.name = 'grpc' include 'grpc-annotation' include 'grpc-client-runtime' include 'grpc-server-runtime' +include 'grpc-server-security' +include 'grpc-server-security-jwt' include 'grpc-runtime' -include 'protobuff-support' \ No newline at end of file +include 'protobuff-support' + diff --git a/src/main/docs/guide/serverSecurity.adoc b/src/main/docs/guide/serverSecurity.adoc new file mode 100644 index 000000000..ef2806aca --- /dev/null +++ b/src/main/docs/guide/serverSecurity.adoc @@ -0,0 +1,78 @@ +=== gRPC Server Security Implementations +Currently, only `JWT` is supported for server-side security. + +==== gRPC Server Security JWT +JWT security is accomplished by an implementation of `io.grpc.ServerInterceptor`. + +If you are already using `io.micronaut.security:micronaut-security-jwt`, then no additional configuration will be required for a basic implemntation. + +===== Configuration + +.Disabling JWT Validation +[source,yaml] +---- +grpc.server.security.token.jwt: + enabled: false # <1> +---- +<1> By default, this is enabled + +If you are not already using `io.micronaut.security:micronaut-security-jwt`, then you will also need to prepare +that configuration as well. Please read these https://micronaut-projects.github.io/micronaut-security/latest/guide/#jwt[docs] or this +https://guides.micronaut.io/micronaut-security-jwt/guide/index.html[guide]. + +===== Interceptor +The default configuration has the interceptor ordered with `@Ordered.HIGHEST_PRECEDENCE` and with +a metadata key name of `JWT`. + +This can be changed by overriding the interceptor configuration as demonstrated below: + +.Customizing JWT interceptor +[source,yaml] +---- +grpc.server.security.token.jwt: + metadata-key-name: AUTHORIZATION # <1> + interceptor-order: 100 # <2> +---- +<1> Use `AUTHORIZATION` as the metadata key name which holds the JSON web token for validation +<2> Set the interceptor order to `100` to control where it will be executed in the server interceptor chain + +===== Interceptor Patterns +By default, all gRPC services and methods will be intercepted. + +This can be changed by overriding the `intercept-url-map` configuration as demonstrated below: + +.Customizing Method Pattern Filters +[source,yaml] +---- +micronaut.security: + intercept-url-map: + - pattern: app.ServiceName/MethodName # <1> + access: + - isAuthenticated() + - pattern: app.OtherServiceName/.* # <2> + access: + - ROLE_OTHER +---- +<1> Matches an exact fully qualified gRPC Service Method Name - see https://grpc.github.io/grpc-java/javadoc/io/grpc/MethodDescriptor.html#getFullMethodName--[gRPC Javadocs]. +<2> Matches on a regular expression of the fully qualified gRPC service method name + +NOTE: This configuration can be shared with `URL` filtering on HTTP requests as well. For more information on this configuration, +please see https://micronaut-projects.github.io/micronaut-security/latest/guide/#interceptUrlMap[Micronaut Security documentation]. + +===== Interceptor Behavior +The server interceptor will throw `io.grpc.StatusRuntimeException` when there is any issue with validating the `JWT`. + +1. **JWT is missing from metadata** - `io.grpc.Status.UNAUTHENTICATED` +2. **JWT provided but is invalid** - `io.grpc.Status.PERMISSION_DENIED` + +This can be changed by overriding the status configuration as demonstrated below: + +.Customizing gRPC Statuses +[source,yaml] +---- +grpc.server.security.token.jwt: + missing-token-status: NOT_FOUND # <1> + failed-validation-token-status: UNAUTHENTICATED # <2> +---- +<1> Return `io.grpc.Status.NOT_FOUND` when JWT is missing from metadata +<2> Return `io.grpc.Status.UNAUTHENTICATED` when JWT is provided but is invalid \ No newline at end of file diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 105309b9d..7f57d8ea3 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -3,6 +3,7 @@ introduction: releaseHistory: Release History gettingStarted: Getting Started server: gRPC Server +serverSecurity: gRPC Server Security client: gRPC Clients serviceDiscovery: Service Discovery tracing: Distributed Tracing