diff --git a/docs/en/server/security.md b/docs/en/server/security.md index 5ec85bbe3..48d2a6f3c 100644 --- a/docs/en/server/security.md +++ b/docs/en/server/security.md @@ -209,6 +209,34 @@ GrpcAuthenticationReader authenticationReader() { See also [Mutual Certificate Authentication](#mutual-certificate-authentication). +#### Using AuthenticationManagerResolver + +You can also use the `AuthenticationManagerResolver` to dynamically determine the authentication manager to use for +a particular request. This can be useful for applications that support multiple authentication +mechanisms, such as OAuth and OpenID Connect, or that want to delegate authentication to external services. + +To use `AuthenticationManagerResolver`, you first need to create a bean that implements +the `AuthenticationManagerResolver` interface instead of `AuthenticationManager`. The `resolve()` method of this bean should +return the AuthenticationManager to use for a particular request. + +````java +@Bean +AuthenticationManagerResolver grpcAuthenticationManagerResolver() { + return grpcServerRequest -> { + AuthenticationManager authenticationManager = // Check the grpc request and return an authenticationManager + return authenticationManager; + }; +} + +@Bean +GrpcAuthenticationReader authenticationReader() { + final List readers = new ArrayList<>(); + // The actual token class is dependent on your spring-security library (OAuth2/JWT/...) + readers.add(new BearerAuthenticationReader(accessToken -> new BearerTokenAuthenticationToken(accessToken))); + return new CompositeGrpcAuthenticationReader(readers); +} +```` + ### Configure Authorization This step is very important as it actually secures your application against unwanted access. You can secure your diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcServerSecurityAutoConfiguration.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcServerSecurityAutoConfiguration.java index 5e8146b66..f4676e38c 100644 --- a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcServerSecurityAutoConfiguration.java +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcServerSecurityAutoConfiguration.java @@ -25,14 +25,12 @@ import org.springframework.security.access.AccessDecisionManager; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.core.AuthenticationException; import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader; import net.devh.boot.grpc.server.security.check.GrpcSecurityMetadataSource; -import net.devh.boot.grpc.server.security.interceptors.AuthenticatingServerInterceptor; -import net.devh.boot.grpc.server.security.interceptors.AuthorizationCheckingServerInterceptor; -import net.devh.boot.grpc.server.security.interceptors.DefaultAuthenticatingServerInterceptor; -import net.devh.boot.grpc.server.security.interceptors.ExceptionTranslatingServerInterceptor; +import net.devh.boot.grpc.server.security.interceptors.*; /** * Auto configuration class with the required beans for the spring-security configuration of the grpc server. @@ -59,7 +57,7 @@ * @author Daniel Theuke (daniel.theuke@heuboe.de) */ @Configuration(proxyBeanMethods = false) -@ConditionalOnBean(AuthenticationManager.class) +@ConditionalOnBean(GrpcAuthenticationReader.class) @AutoConfigureAfter(SecurityAutoConfiguration.class) public class GrpcServerSecurityAutoConfiguration { @@ -83,6 +81,7 @@ public ExceptionTranslatingServerInterceptor exceptionTranslatingServerIntercept * @return The authenticatingServerInterceptor bean. */ @Bean + @ConditionalOnBean(AuthenticationManager.class) @ConditionalOnMissingBean(AuthenticatingServerInterceptor.class) public DefaultAuthenticatingServerInterceptor authenticatingServerInterceptor( final AuthenticationManager authenticationManager, @@ -90,6 +89,23 @@ public DefaultAuthenticatingServerInterceptor authenticatingServerInterceptor( return new DefaultAuthenticatingServerInterceptor(authenticationManager, authenticationReader); } + /** + * The security interceptor that handles the authentication of requests. + * + * @param grpcAuthenticationManagerResolver The authentication manager resolver used to verify the credentials. + * @param authenticationReader The authentication reader used to extract the credentials from the call. + * @return The authenticatingServerInterceptor bean. + */ + @Bean + @ConditionalOnBean(parameterizedContainer = AuthenticationManagerResolver.class, value = GrpcServerRequest.class) + @ConditionalOnMissingBean(AuthenticatingServerInterceptor.class) + public ManagerResolverAuthenticatingServerInterceptor managerResolverAuthenticatingServerInterceptor( + final AuthenticationManagerResolver grpcAuthenticationManagerResolver, + final GrpcAuthenticationReader authenticationReader) { + return new ManagerResolverAuthenticatingServerInterceptor(grpcAuthenticationManagerResolver, + authenticationReader); + } + /** * The security interceptor that handles the authorization of requests. * diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/AbstractAuthenticatingServerInterceptor.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/AbstractAuthenticatingServerInterceptor.java new file mode 100644 index 000000000..866068279 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/AbstractAuthenticatingServerInterceptor.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2016-2023 The gRPC-Spring 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 + * + * http://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 net.devh.boot.grpc.server.security.interceptors; + +import static java.util.Objects.requireNonNull; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import io.grpc.*; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader; + + +/** + * A server interceptor that tries to {@link GrpcAuthenticationReader read} the credentials from the client and + * {@link AuthenticationManager#authenticate(Authentication) authenticate} them. This interceptor sets the + * authentication to both grpc's {@link Context} and {@link SecurityContextHolder}. + * + *

+ * This works similar to the {@code org.springframework.security.web.authentication.AuthenticationFilter}. + *

+ * + *

+ * Note: This interceptor works similar to + * {@link Contexts#interceptCall(Context, ServerCall, Metadata, ServerCallHandler)}. + *

+ */ +@Slf4j +public abstract class AbstractAuthenticatingServerInterceptor implements AuthenticatingServerInterceptor { + + private final GrpcAuthenticationReader grpcAuthenticationReader; + + /** + * Creates a new DefaultAuthenticatingServerInterceptor with the given authentication manager and reader. + * + * @param authenticationReader The authentication reader used to extract the credentials from the call. + */ + protected AbstractAuthenticatingServerInterceptor(final GrpcAuthenticationReader authenticationReader) { + this.grpcAuthenticationReader = requireNonNull(authenticationReader, "authenticationReader"); + } + + @Override + public ServerCall.Listener interceptCall(final ServerCall call, + final Metadata headers, final ServerCallHandler next) { + Authentication authentication; + try { + authentication = this.grpcAuthenticationReader.readAuthentication(call, headers); + } catch (final AuthenticationException e) { + log.debug("Failed to read authentication: {}", e.getMessage()); + throw e; + } + if (authentication == null) { + log.debug("No credentials found: Continuing unauthenticated"); + try { + return next.startCall(call, headers); + } catch (final AccessDeniedException e) { + throw newNoCredentialsException(e); + } + } + if (authentication.getDetails() == null && authentication instanceof AbstractAuthenticationToken) { + // Append call attributes to the authentication request. + // This gives the AuthenticationManager access to information like remote and local address. + // It can then decide whether it wants to use its own user details or the attributes. + ((AbstractAuthenticationToken) authentication).setDetails(call.getAttributes()); + } + log.debug("Credentials found: Authenticating '{}'", authentication.getName()); + + AuthenticationManager authenticationManager = getAuthenticationManager(call, headers); + if (authenticationManager == null) { + log.debug("No authentication manager found"); + throw new InternalAuthenticationServiceException("No authentication manager found"); + } + + try { + authentication = authenticationManager.authenticate(authentication); + } catch (final AuthenticationException e) { + log.debug("Authentication request failed: {}", e.getMessage()); + onUnsuccessfulAuthentication(call, headers, e); + throw e; + } + + final SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + @SuppressWarnings("deprecation") + final Context grpcContext = Context.current().withValues( + SECURITY_CONTEXT_KEY, securityContext, + AUTHENTICATION_CONTEXT_KEY, authentication); + final Context previousContext = grpcContext.attach(); + log.debug("Authentication successful: Continuing as {} ({})", authentication.getName(), + authentication.getAuthorities()); + onSuccessfulAuthentication(call, headers, authentication); + try { + return new AuthenticatingServerCallListener<>(next.startCall(call, headers), grpcContext, securityContext); + } catch (final AccessDeniedException e) { + if (authentication instanceof AnonymousAuthenticationToken) { + throw newNoCredentialsException(e); + } else { + throw e; + } + } finally { + SecurityContextHolder.clearContext(); + grpcContext.detach(previousContext); + log.debug("startCall - Authentication cleared"); + } + } + + /** + * Retrieves the appropriate AuthenticationManager to handle authentication for the given gRPC request. Subclasses + * must implement this method to provide a mechanism for determining the appropriate AuthenticationManager based on + * the specific request context. This allows for dynamic selection of authentication strategies based on factors + * such as request headers, request payload, or other criteria. + * + * @param call The gRPC ServerCall representing the incoming request. + * @param headers The metadata associated with the request, containing potentially relevant authentication + * information. + * @return The AuthenticationManager responsible for authenticating the request. + */ + protected abstract AuthenticationManager getAuthenticationManager( + final ServerCall call, + final Metadata headers); + + /** + * Hook that will be called on successful authentication. Implementations may only use the call instance in a + * non-disruptive manor, that is accessing call attributes or the call descriptor. Implementations must not pollute + * the current thread/context with any call-related state, including authentication, beyond the duration of the + * method invocation. At the time of calling both the grpc context and the security context have been updated to + * reflect the state of the authentication and thus don't have to be setup manually. + * + *

+ * Note: This method is called regardless of whether the authenticated user is authorized or not to perform + * the requested action. + *

+ * + *

+ * By default, this method does nothing. + *

+ * + * @param call The call instance to receive response messages. + * @param headers The headers associated with the call. + * @param authentication The successful authentication instance. + */ + protected void onSuccessfulAuthentication( + final ServerCall call, + final Metadata headers, + final Authentication authentication) { + // Overwrite to add custom behavior. + } + + /** + * Hook that will be called on unsuccessful authentication. Implementations must use the call instance only in a + * non-disruptive manner, i.e. to access call attributes or the call descriptor. Implementations must not close the + * call and must not pollute the current thread/context with any call-related state, including authentication, + * beyond the duration of the method invocation. + * + *

+ * Note: This method is called only if the request contains an authentication but the + * {@link AuthenticationManager} considers it invalid. This method is not called if an authenticated user is not + * authorized to perform the requested action. + *

+ * + *

+ * By default, this method does nothing. + *

+ * + * @param call The call instance to receive response messages. + * @param headers The headers associated with the call. + * @param failed The exception related to the unsuccessful authentication. + */ + protected void onUnsuccessfulAuthentication( + final ServerCall call, + final Metadata headers, + final AuthenticationException failed) { + // Overwrite to add custom behavior. + } + + /** + * Wraps the given {@link AccessDeniedException} in an {@link AuthenticationException} to reflect, that no + * authentication was originally present in the request. + * + * @param denied The caught exception. + * @return The newly created {@link AuthenticationException}. + */ + private static AuthenticationException newNoCredentialsException(final AccessDeniedException denied) { + return new BadCredentialsException("No credentials found in the request", denied); + } + + /** + * A call listener that will set the authentication context using {@link SecurityContextHolder} before each + * invocation and clear it afterwards. + * + * @param The type of the request. + */ + private static class AuthenticatingServerCallListener extends AbstractAuthenticatingServerCallListener { + + private final SecurityContext securityContext; + + /** + * Creates a new AuthenticatingServerCallListener which will attach the given security context before delegating + * to the given listener. + * + * @param delegate The listener to delegate to. + * @param grpcContext The context to attach. + * @param securityContext The security context instance to attach. + */ + public AuthenticatingServerCallListener(final ServerCall.Listener delegate, final Context grpcContext, + final SecurityContext securityContext) { + super(delegate, grpcContext); + this.securityContext = securityContext; + } + + @Override + protected void attachAuthenticationContext() { + SecurityContextHolder.setContext(this.securityContext); + } + + @Override + protected void detachAuthenticationContext() { + SecurityContextHolder.clearContext(); + } + + @Override + public void onHalfClose() { + try { + super.onHalfClose(); + } catch (final AccessDeniedException e) { + if (this.securityContext.getAuthentication() instanceof AnonymousAuthenticationToken) { + throw newNoCredentialsException(e); + } else { + throw e; + } + } + } + + } +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/DefaultAuthenticatingServerInterceptor.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/DefaultAuthenticatingServerInterceptor.java index 45b1a969a..c539de3cd 100644 --- a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/DefaultAuthenticatingServerInterceptor.java +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/DefaultAuthenticatingServerInterceptor.java @@ -18,24 +18,12 @@ import static java.util.Objects.requireNonNull; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import io.grpc.Context; -import io.grpc.Contexts; -import io.grpc.Metadata; -import io.grpc.ServerCall; -import io.grpc.ServerCall.Listener; -import io.grpc.ServerCallHandler; +import io.grpc.*; import lombok.extern.slf4j.Slf4j; import net.devh.boot.grpc.common.util.InterceptorOrder; import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor; @@ -60,10 +48,9 @@ @Slf4j @GrpcGlobalServerInterceptor @Order(InterceptorOrder.ORDER_SECURITY_AUTHENTICATION) -public class DefaultAuthenticatingServerInterceptor implements AuthenticatingServerInterceptor { +public class DefaultAuthenticatingServerInterceptor extends AbstractAuthenticatingServerInterceptor { private final AuthenticationManager authenticationManager; - private final GrpcAuthenticationReader grpcAuthenticationReader; /** * Creates a new DefaultAuthenticatingServerInterceptor with the given authentication manager and reader. @@ -71,184 +58,16 @@ public class DefaultAuthenticatingServerInterceptor implements AuthenticatingSer * @param authenticationManager The authentication manager used to verify the credentials. * @param authenticationReader The authentication reader used to extract the credentials from the call. */ - @Autowired public DefaultAuthenticatingServerInterceptor(final AuthenticationManager authenticationManager, final GrpcAuthenticationReader authenticationReader) { + super(authenticationReader); this.authenticationManager = requireNonNull(authenticationManager, "authenticationManager"); - this.grpcAuthenticationReader = requireNonNull(authenticationReader, "authenticationReader"); } @Override - public ServerCall.Listener interceptCall(final ServerCall call, - final Metadata headers, final ServerCallHandler next) { - Authentication authentication; - try { - authentication = this.grpcAuthenticationReader.readAuthentication(call, headers); - } catch (final AuthenticationException e) { - log.debug("Failed to read authentication: {}", e.getMessage()); - throw e; - } - if (authentication == null) { - log.debug("No credentials found: Continuing unauthenticated"); - try { - return next.startCall(call, headers); - } catch (final AccessDeniedException e) { - throw newNoCredentialsException(e); - } - } - if (authentication.getDetails() == null && authentication instanceof AbstractAuthenticationToken) { - // Append call attributes to the authentication request. - // This gives the AuthenticationManager access to information like remote and local address. - // It can then decide whether it wants to use its own user details or the attributes. - ((AbstractAuthenticationToken) authentication).setDetails(call.getAttributes()); - } - log.debug("Credentials found: Authenticating '{}'", authentication.getName()); - try { - authentication = this.authenticationManager.authenticate(authentication); - } catch (final AuthenticationException e) { - log.debug("Authentication request failed: {}", e.getMessage()); - onUnsuccessfulAuthentication(call, headers, e); - throw e; - } - - final SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - securityContext.setAuthentication(authentication); - SecurityContextHolder.setContext(securityContext); - @SuppressWarnings("deprecation") - final Context grpcContext = Context.current().withValues( - SECURITY_CONTEXT_KEY, securityContext, - AUTHENTICATION_CONTEXT_KEY, authentication); - final Context previousContext = grpcContext.attach(); - log.debug("Authentication successful: Continuing as {} ({})", authentication.getName(), - authentication.getAuthorities()); - onSuccessfulAuthentication(call, headers, authentication); - try { - return new AuthenticatingServerCallListener<>(next.startCall(call, headers), grpcContext, securityContext); - } catch (final AccessDeniedException e) { - if (authentication instanceof AnonymousAuthenticationToken) { - throw newNoCredentialsException(e); - } else { - throw e; - } - } finally { - SecurityContextHolder.clearContext(); - grpcContext.detach(previousContext); - log.debug("startCall - Authentication cleared"); - } - } - - /** - * Hook that will be called on successful authentication. Implementations may only use the call instance in a - * non-disruptive manor, that is accessing call attributes or the call descriptor. Implementations must not pollute - * the current thread/context with any call-related state, including authentication, beyond the duration of the - * method invocation. At the time of calling both the grpc context and the security context have been updated to - * reflect the state of the authentication and thus don't have to be setup manually. - * - *

- * Note: This method is called regardless of whether the authenticated user is authorized or not to perform - * the requested action. - *

- * - *

- * By default, this method does nothing. - *

- * - * @param call The call instance to receive response messages. - * @param headers The headers associated with the call. - * @param authentication The successful authentication instance. - */ - protected void onSuccessfulAuthentication( + public AuthenticationManager getAuthenticationManager( final ServerCall call, - final Metadata headers, - final Authentication authentication) { - // Overwrite to add custom behavior. + final Metadata headers) { + return authenticationManager; } - - /** - * Hook that will be called on unsuccessful authentication. Implementations must use the call instance only in a - * non-disruptive manner, i.e. to access call attributes or the call descriptor. Implementations must not close the - * call and must not pollute the current thread/context with any call-related state, including authentication, - * beyond the duration of the method invocation. - * - *

- * Note: This method is called only if the request contains an authentication but the - * {@link AuthenticationManager} considers it invalid. This method is not called if an authenticated user is not - * authorized to perform the requested action. - *

- * - *

- * By default, this method does nothing. - *

- * - * @param call The call instance to receive response messages. - * @param headers The headers associated with the call. - * @param failed The exception related to the unsuccessful authentication. - */ - protected void onUnsuccessfulAuthentication( - final ServerCall call, - final Metadata headers, - final AuthenticationException failed) { - // Overwrite to add custom behavior. - } - - /** - * Wraps the given {@link AccessDeniedException} in an {@link AuthenticationException} to reflect, that no - * authentication was originally present in the request. - * - * @param denied The caught exception. - * @return The newly created {@link AuthenticationException}. - */ - private static AuthenticationException newNoCredentialsException(final AccessDeniedException denied) { - return new BadCredentialsException("No credentials found in the request", denied); - } - - /** - * A call listener that will set the authentication context using {@link SecurityContextHolder} before each - * invocation and clear it afterwards. - * - * @param The type of the request. - */ - private static class AuthenticatingServerCallListener extends AbstractAuthenticatingServerCallListener { - - private final SecurityContext securityContext; - - /** - * Creates a new AuthenticatingServerCallListener which will attach the given security context before delegating - * to the given listener. - * - * @param delegate The listener to delegate to. - * @param grpcContext The context to attach. - * @param securityContext The security context instance to attach. - */ - public AuthenticatingServerCallListener(final Listener delegate, final Context grpcContext, - final SecurityContext securityContext) { - super(delegate, grpcContext); - this.securityContext = securityContext; - } - - @Override - protected void attachAuthenticationContext() { - SecurityContextHolder.setContext(this.securityContext); - } - - @Override - protected void detachAuthenticationContext() { - SecurityContextHolder.clearContext(); - } - - @Override - public void onHalfClose() { - try { - super.onHalfClose(); - } catch (final AccessDeniedException e) { - if (this.securityContext.getAuthentication() instanceof AnonymousAuthenticationToken) { - throw newNoCredentialsException(e); - } else { - throw e; - } - } - } - - } - } diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/GrpcServerRequest.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/GrpcServerRequest.java new file mode 100644 index 000000000..083280aff --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/GrpcServerRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2016-2024 The gRPC-Spring 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 + * + * http://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 net.devh.boot.grpc.server.security.interceptors; + +import static java.util.Objects.requireNonNull; + +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; + +/** + * Allows access gRPC specific aspects of a server request during sampling and parsing. + * + * @author Sajad Mehrabi (mehrabisajad@gmail.com) + */ +public record GrpcServerRequest(ServerCall call, Metadata headers) { + public GrpcServerRequest(ServerCall call, Metadata headers) { + this.call = requireNonNull(call, "call"); + this.headers = requireNonNull(headers, "headers"); + } + + /** + * Returns {@linkplain ServerCall#getMethodDescriptor()}} from the {@link #call()}. + */ + public MethodDescriptor methodDescriptor() { + return call.getMethodDescriptor(); + } + +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/ManagerResolverAuthenticatingServerInterceptor.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/ManagerResolverAuthenticatingServerInterceptor.java new file mode 100644 index 000000000..e32793f85 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/security/interceptors/ManagerResolverAuthenticatingServerInterceptor.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2016-2023 The gRPC-Spring 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 + * + * http://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 net.devh.boot.grpc.server.security.interceptors; + +import static java.util.Objects.requireNonNull; + +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import io.grpc.*; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.common.util.InterceptorOrder; +import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor; +import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader; + +/** + * A server interceptor that tries to {@link GrpcAuthenticationReader read} the credentials from the client and + * {@link AuthenticationManager#authenticate(Authentication) authenticate} them. This interceptor sets the + * authentication to both grpc's {@link Context} and {@link SecurityContextHolder}. + * + *

+ * This works similar to the {@code org.springframework.security.web.authentication.AuthenticationFilter}. + *

+ * + *

+ * Note: This interceptor works similar to + * {@link Contexts#interceptCall(Context, ServerCall, Metadata, ServerCallHandler)}. + *

+ * + * @author Sajad Mehrabi (mehrabisajad@gmail.com) + */ +@Slf4j +@GrpcGlobalServerInterceptor +@Order(InterceptorOrder.ORDER_SECURITY_AUTHENTICATION) +public class ManagerResolverAuthenticatingServerInterceptor extends AbstractAuthenticatingServerInterceptor { + + private final AuthenticationManagerResolver authenticationManagerResolver; + + /** + * Creates a new ManagerResolverAuthenticatingServerInterceptor with the given authentication manager resolver and + * reader. + * + * @param authenticationManagerResolver The authentication manager resolver used to verify the credentials. + * @param authenticationReader The authentication reader used to extract the credentials from the call. + */ + public ManagerResolverAuthenticatingServerInterceptor( + final AuthenticationManagerResolver authenticationManagerResolver, + final GrpcAuthenticationReader authenticationReader) { + super(authenticationReader); + this.authenticationManagerResolver = + requireNonNull(authenticationManagerResolver, "authenticationManagerResolver"); + } + + @Override + protected AuthenticationManager getAuthenticationManager( + final ServerCall call, + final Metadata headers) { + GrpcServerRequest grpcServerRequest = new GrpcServerRequest(call, headers); + return this.authenticationManagerResolver.resolve(grpcServerRequest); + } +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/config/WithBasicAuthAndManagerResolverSecurityConfiguration.java b/tests/src/test/java/net/devh/boot/grpc/test/config/WithBasicAuthAndManagerResolverSecurityConfiguration.java new file mode 100644 index 000000000..e2ba65a2e --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/config/WithBasicAuthAndManagerResolverSecurityConfiguration.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2016-2023 The gRPC-Spring 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 + * + * http://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 net.devh.boot.grpc.test.config; + +import static net.devh.boot.grpc.client.security.CallCredentialsHelper.basicAuth; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import com.google.common.collect.ImmutableMap; + +import io.grpc.CallCredentials; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.StubTransformer; +import net.devh.boot.grpc.client.security.CallCredentialsHelper; +import net.devh.boot.grpc.server.security.authentication.*; +import net.devh.boot.grpc.server.security.interceptors.GrpcServerRequest; + +@Slf4j +@Configuration +public class WithBasicAuthAndManagerResolverSecurityConfiguration { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(10); + } + + @Bean + UserDetailsService userDetailsService() { + return username -> { + log.debug("Searching user: {}", username); + if (username.length() > 10) { + throw new UsernameNotFoundException("Could not find user!"); + } + final List authorities = + Arrays.asList(new SimpleGrantedAuthority("ROLE_" + username.toUpperCase())); + return new User(username, passwordEncoder().encode(username), authorities); + }; + } + + @Bean + DaoAuthenticationProvider daoAuthenticationProvider() { + final DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService()); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + InMemoryUserDetailsManager inMemoryUserDetailsManager() { + PasswordEncoder passwordEncoder = passwordEncoder(); + InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager(); + userDetailsService.createUser(new User("client1", passwordEncoder.encode("client1"), + List.of(new SimpleGrantedAuthority("ROLE_CLIENT1")))); + userDetailsService.createUser(new User("client2", passwordEncoder.encode("client2"), + List.of(new SimpleGrantedAuthority("ROLE_CLIENT2")))); + return userDetailsService; + } + + DaoAuthenticationProvider daoAuthenticationProviderInMemory() { + final DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(inMemoryUserDetailsManager()); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + AuthenticationManagerResolver authenticationManager() { + return context -> { + String methodName = context.methodDescriptor().getFullMethodName(); + if (methodName.equals("TestService/normal")) { + return daoAuthenticationProviderInMemory()::authenticate; + } else { + final List providers = new ArrayList<>(); + providers.add(daoAuthenticationProvider()); + return new ProviderManager(providers); + } + }; + } + + @Bean + GrpcAuthenticationReader authenticationReader() { + final List readers = new ArrayList<>(); + readers.add(new BasicGrpcAuthenticationReader()); + return new CompositeGrpcAuthenticationReader(readers); + } + + // Client-Side + + @Bean + StubTransformer mappedCredentialsStubTransformer() { + return CallCredentialsHelper.mappedCredentialsStubTransformer(ImmutableMap.builder() + .put("test", testCallCredentials("client1")) + .put("test-secondary", testCallCredentials("client1")) + .put("noPerm", testCallCredentials("client2")) + .put("noPerm-secondary", testCallCredentials("client2")) + .put("unknownUser", testCallCredentials("unknownUser")) + // .put("noAuth", null) + .build()); + } + + private CallCredentials testCallCredentials(final String username) { + return basicAuth(username, username); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/security/ManualSecurityWithBasicAuthAndManagerResolverTest.java b/tests/src/test/java/net/devh/boot/grpc/test/security/ManualSecurityWithBasicAuthAndManagerResolverTest.java new file mode 100644 index 000000000..165738832 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/security/ManualSecurityWithBasicAuthAndManagerResolverTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2016-2023 The gRPC-Spring 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 + * + * http://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 net.devh.boot.grpc.test.security; + +import static io.grpc.Status.Code.PERMISSION_DENIED; + +import org.junit.jupiter.api.TestFactory; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import net.devh.boot.grpc.test.config.*; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceBlockingStub; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceFutureStub; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceStub; +import net.devh.boot.grpc.test.util.DynamicTestCollection; + +/** + * A test checking that the server and client can start and connect to each other with minimal config by authentication + * manager resolver. + */ +@Slf4j +@SpringBootTest +@SpringJUnitConfig( + classes = {ServiceConfiguration.class, DualInProcessConfiguration.class, BaseAutoConfiguration.class, + ManualSecurityConfiguration.class, WithBasicAuthAndManagerResolverSecurityConfiguration.class}) +@DirtiesContext +class ManualSecurityWithBasicAuthAndManagerResolverTest extends AbstractSecurityWithBasicAuthTest { + + // The secondary stubs use the secondary server + + @GrpcClient("test-secondary") + protected TestServiceStub serviceStubSecondary; + @GrpcClient("test-secondary") + protected TestServiceBlockingStub blockingStubSecondary; + @GrpcClient("test-secondary") + protected TestServiceFutureStub futureStubSecondary; + + @GrpcClient("noPerm-secondary") + protected TestServiceStub noPermStubSecondary; + @GrpcClient("noPerm-secondary") + protected TestServiceBlockingStub noPermBlockingStubSecondary; + @GrpcClient("noPerm-secondary") + protected TestServiceFutureStub noPermFutureStubSecondary; + + ManualSecurityWithBasicAuthAndManagerResolverTest() { + log.info("--- ManualSecurityWithBasicAuthAndManagerResolverTest ---"); + } + + @Override + @DirtiesContext + @TestFactory + DynamicTestCollection unprotectedCallTests() { + return super.unprotectedCallTests() + .add("unprotected-secondary", + () -> assertNormalCallSuccess(this.serviceStubSecondary, this.blockingStubSecondary, + this.futureStubSecondary)) + .add("unprotected-noPerm-secondary", + () -> assertNormalCallSuccess(this.noPermStubSecondary, this.noPermBlockingStubSecondary, + this.noPermFutureStubSecondary)); + } + + @Override + @DirtiesContext + @TestFactory + DynamicTestCollection unaryCallTest() { + return super.unaryCallTest() + .add("unary-secondary", + () -> assertUnaryCallSuccess(this.serviceStubSecondary, this.blockingStubSecondary, + this.futureStubSecondary)) + .add("unary-noPerm-secondary", + () -> assertUnaryCallFailure(this.noPermStubSecondary, this.noPermBlockingStubSecondary, + this.noPermFutureStubSecondary, PERMISSION_DENIED)); + } + + @Override + @DirtiesContext + @TestFactory + DynamicTestCollection clientStreamingCallTests() { + return super.clientStreamingCallTests() + .add("clientStreaming-secondary", + () -> assertClientStreamingCallFailure(this.serviceStubSecondary, PERMISSION_DENIED)) + .add("clientStreaming-noPerm-secondary", + () -> assertClientStreamingCallFailure(this.noPermStubSecondary, PERMISSION_DENIED)); + } + + @Override + @DirtiesContext + @TestFactory + DynamicTestCollection serverStreamingCallTests() { + return super.serverStreamingCallTests() + .add("serverStreaming-secondary", + () -> assertServerStreamingCallSuccess(this.serviceStubSecondary)) + .add("serverStreaming-noPerm-secondary", + () -> assertServerStreamingCallSuccess(this.noPermStubSecondary)); + } + + @Override + @DirtiesContext + @TestFactory + DynamicTestCollection bidiStreamingCallTests() { + return super.bidiStreamingCallTests() + .add("bidiStreaming-secondary", + () -> assertServerStreamingCallSuccess(this.serviceStubSecondary)) + .add("bidiStreaming-noPerm-secondary", + () -> assertServerStreamingCallSuccess(this.noPermStubSecondary)); + } + +}