From 827384e38602832cc67b3a1a31b4cff8c8b37042 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 21 Sep 2022 18:13:32 -0600 Subject: [PATCH 1/7] Add Micrometer Dependency --- core/spring-security-core.gradle | 1 + dependencies/spring-security-dependencies.gradle | 1 + gradle.properties | 2 ++ 3 files changed, 4 insertions(+) diff --git a/core/spring-security-core.gradle b/core/spring-security-core.gradle index 2968d3d95ff..fcedeea9066 100644 --- a/core/spring-security-core.gradle +++ b/core/spring-security-core.gradle @@ -10,6 +10,7 @@ dependencies { api 'org.springframework:spring-context' api 'org.springframework:spring-core' api 'org.springframework:spring-expression' + api 'io.micrometer:micrometer-observation' optional 'com.fasterxml.jackson.core:jackson-databind' optional 'io.projectreactor:reactor-core' diff --git a/dependencies/spring-security-dependencies.gradle b/dependencies/spring-security-dependencies.gradle index 2089728baf2..0207b7e6d6d 100644 --- a/dependencies/spring-security-dependencies.gradle +++ b/dependencies/spring-security-dependencies.gradle @@ -26,6 +26,7 @@ dependencies { api "com.unboundid:unboundid-ldapsdk:6.0.6" api "commons-collections:commons-collections:3.2.2" api "io.mockk:mockk:1.12.8" + api "io.micrometer:micrometer-observation:$micrometerVersion" api "jakarta.annotation:jakarta.annotation-api:2.1.1" api "jakarta.inject:jakarta.inject-api:2.0.1" api "jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:2.0.0" diff --git a/gradle.properties b/gradle.properties index db37be03f4f..25ae0a61818 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,8 @@ reactorVersion=2022.0.0-SNAPSHOT springJavaformatVersion=0.0.34 springBootVersion=2.4.2 springFrameworkVersion=6.0.0-SNAPSHOT +springFrameworkVersion=6.0.0-SNAPSHOT +micrometerVersion=1.10.0-SNAPSHOT openSamlVersion=4.1.1 version=6.0.0-SNAPSHOT kotlinVersion=1.7.10 From 8c610684f3f6b25d05b9b28d7f6c640e05bc327c Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 21 Sep 2022 18:03:03 -0600 Subject: [PATCH 2/7] Instrument Authentication and Authorization Closes gh-11989 Closes gh-11990 --- .../Jsr250MethodSecurityConfiguration.java | 39 +++--- .../PrePostMethodSecurityConfiguration.java | 124 +++++++++--------- ...ionManagerMethodSecurityConfiguration.java | 38 ++++-- .../SecuredMethodSecurityConfiguration.java | 32 +++-- .../rsocket/RSocketSecurityConfiguration.java | 13 ++ .../annotation/web/builders/HttpSecurity.java | 20 ++- .../AuthorizeHttpRequestsConfigurer.java | 20 ++- .../ServerHttpSecurityConfiguration.java | 13 ++ ...ketMessageBrokerSecurityConfiguration.java | 28 ++-- .../AuthenticationManagerFactoryBean.java | 15 ++- .../http/AuthorizationFilterParser.java | 66 ++++++++-- .../HttpSecurityBeanDefinitionParser.java | 77 ++++++++++- .../MethodSecurityBeanDefinitionParser.java | 117 +++++++++++++++-- .../config/web/server/ServerHttpSecurity.java | 17 ++- .../security/config/spring-security-6.0.rnc | 9 ++ .../security/config/spring-security-6.0.xsd | 18 +++ .../AuthenticationObservationContext.java | 92 +++++++++++++ .../AuthenticationObservationConvention.java | 94 +++++++++++++ .../ObservationAuthenticationManager.java | 59 +++++++++ ...ervationReactiveAuthenticationManager.java | 61 +++++++++ .../AuthorizationObservationContext.java | 87 ++++++++++++ .../AuthorizationObservationConvention.java | 102 ++++++++++++++ .../ObservationAuthorizationManager.java | 71 ++++++++++ ...servationReactiveAuthorizationManager.java | 66 ++++++++++ ...rizationManagerAfterMethodInterceptor.java | 14 ++ ...izationManagerBeforeMethodInterceptor.java | 43 ++++++ ...ObservationAuthenticationManagerTests.java | 96 ++++++++++++++ ...ionReactiveAuthenticationManagerTests.java | 99 ++++++++++++++ .../ObservationAuthorizationManagerTests.java | 121 +++++++++++++++++ ...tionReactiveAuthorizationManagerTests.java | 120 +++++++++++++++++ 30 files changed, 1641 insertions(+), 130 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/authentication/AuthenticationObservationContext.java create mode 100644 core/src/main/java/org/springframework/security/authentication/AuthenticationObservationConvention.java create mode 100644 core/src/main/java/org/springframework/security/authentication/ObservationAuthenticationManager.java create mode 100644 core/src/main/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManager.java create mode 100644 core/src/main/java/org/springframework/security/authorization/AuthorizationObservationContext.java create mode 100644 core/src/main/java/org/springframework/security/authorization/AuthorizationObservationConvention.java create mode 100644 core/src/main/java/org/springframework/security/authorization/ObservationAuthorizationManager.java create mode 100644 core/src/main/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManager.java create mode 100644 core/src/test/java/org/springframework/security/authentication/ObservationAuthenticationManagerTests.java create mode 100644 core/src/test/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManagerTests.java create mode 100644 core/src/test/java/org/springframework/security/authorization/ObservationAuthorizationManagerTests.java create mode 100644 core/src/test/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManagerTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java index 95a58417650..a1210652ee7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/Jsr250MethodSecurityConfiguration.java @@ -16,12 +16,17 @@ package org.springframework.security.config.annotation.method.configuration; +import io.micrometer.observation.ObservationRegistry; +import org.aopalliance.intercept.MethodInvocation; + import org.springframework.aop.Advisor; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.authorization.method.Jsr250AuthorizationManager; import org.springframework.security.config.core.GrantedAuthorityDefaults; @@ -40,28 +45,28 @@ @Role(BeanDefinition.ROLE_INFRASTRUCTURE) final class Jsr250MethodSecurityConfiguration { - private final Jsr250AuthorizationManager jsr250AuthorizationManager = new Jsr250AuthorizationManager(); - - private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder - .getContextHolderStrategy(); - @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor jsr250AuthorizationMethodInterceptor() { + static Advisor jsr250AuthorizationMethodInterceptor(ObjectProvider defaultsProvider, + ObjectProvider strategyProvider, + ObjectProvider registryProvider) { + Jsr250AuthorizationManager jsr250 = new Jsr250AuthorizationManager(); + defaultsProvider.ifAvailable((d) -> jsr250.setRolePrefix(d.getRolePrefix())); + SecurityContextHolderStrategy strategy = strategyProvider + .getIfAvailable(SecurityContextHolder::getContextHolderStrategy); + ObservationRegistry registry = registryProvider.getIfAvailable(() -> ObservationRegistry.NOOP); + AuthorizationManager manager = manager(jsr250, registry); AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor - .jsr250(this.jsr250AuthorizationManager); - interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); + .jsr250(manager); + interceptor.setSecurityContextHolderStrategy(strategy); return interceptor; } - @Autowired(required = false) - void setGrantedAuthorityDefaults(GrantedAuthorityDefaults grantedAuthorityDefaults) { - this.jsr250AuthorizationManager.setRolePrefix(grantedAuthorityDefaults.getRolePrefix()); - } - - @Autowired(required = false) - void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { - this.securityContextHolderStrategy = securityContextHolderStrategy; + static AuthorizationManager manager(AuthorizationManager jsr250, ObservationRegistry registry) { + if (registry.isNoop()) { + return jsr250; + } + return new ObservationAuthorizationManager<>(registry, jsr250); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java index 1c2a48be720..79a5c54fa7c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java @@ -16,8 +16,10 @@ package org.springframework.security.config.annotation.method.configuration; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.aop.Advisor; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; @@ -26,7 +28,8 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authorization.AuthorizationEventPublisher; -import org.springframework.security.authorization.SpringAuthorizationEventPublisher; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.authorization.method.PostAuthorizeAuthorizationManager; @@ -48,85 +51,80 @@ @Role(BeanDefinition.ROLE_INFRASTRUCTURE) final class PrePostMethodSecurityConfiguration { - private final PreFilterAuthorizationMethodInterceptor preFilterAuthorizationMethodInterceptor = new PreFilterAuthorizationMethodInterceptor(); - - private final AuthorizationManagerBeforeMethodInterceptor preAuthorizeAuthorizationMethodInterceptor; - - private final PreAuthorizeAuthorizationManager preAuthorizeAuthorizationManager = new PreAuthorizeAuthorizationManager(); - - private final AuthorizationManagerAfterMethodInterceptor postAuthorizeAuthorizaitonMethodInterceptor; - - private final PostAuthorizeAuthorizationManager postAuthorizeAuthorizationManager = new PostAuthorizeAuthorizationManager(); - - private final PostFilterAuthorizationMethodInterceptor postFilterAuthorizationMethodInterceptor = new PostFilterAuthorizationMethodInterceptor(); - - private final DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); - - @Autowired - PrePostMethodSecurityConfiguration(ApplicationContext context) { - this.preAuthorizeAuthorizationManager.setExpressionHandler(this.expressionHandler); - this.preAuthorizeAuthorizationMethodInterceptor = AuthorizationManagerBeforeMethodInterceptor - .preAuthorize(this.preAuthorizeAuthorizationManager); - this.postAuthorizeAuthorizationManager.setExpressionHandler(this.expressionHandler); - this.postAuthorizeAuthorizaitonMethodInterceptor = AuthorizationManagerAfterMethodInterceptor - .postAuthorize(this.postAuthorizeAuthorizationManager); - this.preFilterAuthorizationMethodInterceptor.setExpressionHandler(this.expressionHandler); - this.postFilterAuthorizationMethodInterceptor.setExpressionHandler(this.expressionHandler); - this.expressionHandler.setApplicationContext(context); - AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(context); - this.preAuthorizeAuthorizationMethodInterceptor.setAuthorizationEventPublisher(publisher); - this.postAuthorizeAuthorizaitonMethodInterceptor.setAuthorizationEventPublisher(publisher); - } - @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor preFilterAuthorizationMethodInterceptor() { - return this.preFilterAuthorizationMethodInterceptor; + static Advisor preFilterAuthorizationMethodInterceptor(ObjectProvider defaultsProvider, + ObjectProvider expressionHandlerProvider, + ObjectProvider strategyProvider, ApplicationContext context) { + PreFilterAuthorizationMethodInterceptor preFilter = new PreFilterAuthorizationMethodInterceptor(); + strategyProvider.ifAvailable(preFilter::setSecurityContextHolderStrategy); + preFilter.setExpressionHandler( + expressionHandlerProvider.getIfAvailable(() -> defaultExpressionHandler(defaultsProvider, context))); + return preFilter; } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor preAuthorizeAuthorizationMethodInterceptor() { - return this.preAuthorizeAuthorizationMethodInterceptor; + static Advisor preAuthorizeAuthorizationMethodInterceptor(ObjectProvider defaultsProvider, + ObjectProvider expressionHandlerProvider, + ObjectProvider strategyProvider, + ObjectProvider eventPublisherProvider, + ObjectProvider registryProvider, ApplicationContext context) { + PreAuthorizeAuthorizationManager manager = new PreAuthorizeAuthorizationManager(); + manager.setExpressionHandler( + expressionHandlerProvider.getIfAvailable(() -> defaultExpressionHandler(defaultsProvider, context))); + AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor + .preAuthorize(manager(manager, registryProvider)); + strategyProvider.ifAvailable(preAuthorize::setSecurityContextHolderStrategy); + eventPublisherProvider.ifAvailable(preAuthorize::setAuthorizationEventPublisher); + return preAuthorize; } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor postAuthorizeAuthorizationMethodInterceptor() { - return this.postAuthorizeAuthorizaitonMethodInterceptor; + static Advisor postAuthorizeAuthorizationMethodInterceptor( + ObjectProvider defaultsProvider, + ObjectProvider expressionHandlerProvider, + ObjectProvider strategyProvider, + ObjectProvider eventPublisherProvider, + ObjectProvider registryProvider, ApplicationContext context) { + PostAuthorizeAuthorizationManager manager = new PostAuthorizeAuthorizationManager(); + manager.setExpressionHandler( + expressionHandlerProvider.getIfAvailable(() -> defaultExpressionHandler(defaultsProvider, context))); + AuthorizationManagerAfterMethodInterceptor postAuthorize = AuthorizationManagerAfterMethodInterceptor + .postAuthorize(manager(manager, registryProvider)); + strategyProvider.ifAvailable(postAuthorize::setSecurityContextHolderStrategy); + eventPublisherProvider.ifAvailable(postAuthorize::setAuthorizationEventPublisher); + return postAuthorize; } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor postFilterAuthorizationMethodInterceptor() { - return this.postFilterAuthorizationMethodInterceptor; - } - - @Autowired(required = false) - void setMethodSecurityExpressionHandler(MethodSecurityExpressionHandler methodSecurityExpressionHandler) { - this.preFilterAuthorizationMethodInterceptor.setExpressionHandler(methodSecurityExpressionHandler); - this.preAuthorizeAuthorizationManager.setExpressionHandler(methodSecurityExpressionHandler); - this.postAuthorizeAuthorizationManager.setExpressionHandler(methodSecurityExpressionHandler); - this.postFilterAuthorizationMethodInterceptor.setExpressionHandler(methodSecurityExpressionHandler); - } - - @Autowired(required = false) - void setSecurityContextHolderStrategy(SecurityContextHolderStrategy strategy) { - this.preFilterAuthorizationMethodInterceptor.setSecurityContextHolderStrategy(strategy); - this.preAuthorizeAuthorizationMethodInterceptor.setSecurityContextHolderStrategy(strategy); - this.postAuthorizeAuthorizaitonMethodInterceptor.setSecurityContextHolderStrategy(strategy); - this.postFilterAuthorizationMethodInterceptor.setSecurityContextHolderStrategy(strategy); + static Advisor postFilterAuthorizationMethodInterceptor(ObjectProvider defaultsProvider, + ObjectProvider expressionHandlerProvider, + ObjectProvider strategyProvider, ApplicationContext context) { + PostFilterAuthorizationMethodInterceptor postFilter = new PostFilterAuthorizationMethodInterceptor(); + strategyProvider.ifAvailable(postFilter::setSecurityContextHolderStrategy); + postFilter.setExpressionHandler( + expressionHandlerProvider.getIfAvailable(() -> defaultExpressionHandler(defaultsProvider, context))); + return postFilter; } - @Autowired(required = false) - void setGrantedAuthorityDefaults(GrantedAuthorityDefaults grantedAuthorityDefaults) { - this.expressionHandler.setDefaultRolePrefix(grantedAuthorityDefaults.getRolePrefix()); + private static MethodSecurityExpressionHandler defaultExpressionHandler( + ObjectProvider defaultsProvider, ApplicationContext context) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + defaultsProvider.ifAvailable((d) -> handler.setDefaultRolePrefix(d.getRolePrefix())); + handler.setApplicationContext(context); + return handler; } - @Autowired(required = false) - void setAuthorizationEventPublisher(AuthorizationEventPublisher eventPublisher) { - this.preAuthorizeAuthorizationMethodInterceptor.setAuthorizationEventPublisher(eventPublisher); - this.postAuthorizeAuthorizaitonMethodInterceptor.setAuthorizationEventPublisher(eventPublisher); + static AuthorizationManager manager(AuthorizationManager delegate, + ObjectProvider registryProvider) { + ObservationRegistry registry = registryProvider.getIfAvailable(() -> ObservationRegistry.NOOP); + if (registry.isNoop()) { + return delegate; + } + return new ObservationAuthorizationManager<>(registry, delegate); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java index b2ed2ee22d4..40e498e512c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java @@ -16,6 +16,10 @@ package org.springframework.security.config.annotation.method.configuration; +import io.micrometer.observation.ObservationRegistry; +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; @@ -24,8 +28,11 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; +import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptor; +import org.springframework.security.authorization.method.MethodInvocationResult; import org.springframework.security.authorization.method.PostAuthorizeReactiveAuthorizationManager; import org.springframework.security.authorization.method.PostFilterAuthorizationReactiveMethodInterceptor; import org.springframework.security.authorization.method.PreAuthorizeReactiveAuthorizationManager; @@ -43,39 +50,39 @@ final class ReactiveAuthorizationManagerMethodSecurityConfiguration { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - PreFilterAuthorizationReactiveMethodInterceptor preFilterInterceptor( + static PreFilterAuthorizationReactiveMethodInterceptor preFilterInterceptor( MethodSecurityExpressionHandler expressionHandler) { return new PreFilterAuthorizationReactiveMethodInterceptor(expressionHandler); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeInterceptor( - MethodSecurityExpressionHandler expressionHandler) { - PreAuthorizeReactiveAuthorizationManager authorizationManager = new PreAuthorizeReactiveAuthorizationManager( - expressionHandler); + static AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeInterceptor( + MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider) { + ReactiveAuthorizationManager authorizationManager = manager( + new PreAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider); return AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize(authorizationManager); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - PostFilterAuthorizationReactiveMethodInterceptor postFilterInterceptor( + static PostFilterAuthorizationReactiveMethodInterceptor postFilterInterceptor( MethodSecurityExpressionHandler expressionHandler) { return new PostFilterAuthorizationReactiveMethodInterceptor(expressionHandler); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeInterceptor( - MethodSecurityExpressionHandler expressionHandler) { - PostAuthorizeReactiveAuthorizationManager authorizationManager = new PostAuthorizeReactiveAuthorizationManager( - expressionHandler); + static AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeInterceptor( + MethodSecurityExpressionHandler expressionHandler, ObjectProvider registryProvider) { + ReactiveAuthorizationManager authorizationManager = manager( + new PostAuthorizeReactiveAuthorizationManager(expressionHandler), registryProvider); return AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize(authorizationManager); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler( + static DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler( @Autowired(required = false) GrantedAuthorityDefaults grantedAuthorityDefaults) { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); if (grantedAuthorityDefaults != null) { @@ -84,4 +91,13 @@ DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler( return handler; } + static ReactiveAuthorizationManager manager(ReactiveAuthorizationManager delegate, + ObjectProvider registryProvider) { + ObservationRegistry registry = registryProvider.getIfAvailable(() -> ObservationRegistry.NOOP); + if (registry.isNoop()) { + return delegate; + } + return new ObservationReactiveAuthorizationManager<>(registry, delegate); + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java index 2e30c747a42..9c93ecfb756 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/SecuredMethodSecurityConfiguration.java @@ -16,14 +16,20 @@ package org.springframework.security.config.annotation.method.configuration; +import io.micrometer.observation.ObservationRegistry; +import org.aopalliance.intercept.MethodInvocation; + import org.springframework.aop.Advisor; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; import org.springframework.security.access.annotation.Secured; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; +import org.springframework.security.authorization.method.SecuredAuthorizationManager; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; @@ -39,20 +45,26 @@ @Role(BeanDefinition.ROLE_INFRASTRUCTURE) final class SecuredMethodSecurityConfiguration { - private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder - .getContextHolderStrategy(); - @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - Advisor securedAuthorizationMethodInterceptor() { - AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor.secured(); - interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); + static Advisor securedAuthorizationMethodInterceptor(ObjectProvider strategyProvider, + ObjectProvider registryProvider) { + SecuredAuthorizationManager secured = new SecuredAuthorizationManager(); + SecurityContextHolderStrategy strategy = strategyProvider + .getIfAvailable(SecurityContextHolder::getContextHolderStrategy); + ObservationRegistry registry = registryProvider.getIfAvailable(() -> ObservationRegistry.NOOP); + AuthorizationManager manager = manager(secured, registry); + AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor + .secured(manager); + interceptor.setSecurityContextHolderStrategy(strategy); return interceptor; } - @Autowired(required = false) - void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { - this.securityContextHolderStrategy = securityContextHolderStrategy; + static AuthorizationManager manager(AuthorizationManager jsr250, ObservationRegistry registry) { + if (registry.isNoop()) { + return jsr250; + } + return new ObservationAuthorizationManager<>(registry, jsr250); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/rsocket/RSocketSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/rsocket/RSocketSecurityConfiguration.java index ea5c2f1a32e..9f7f5c9c5ba 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/rsocket/RSocketSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/rsocket/RSocketSecurityConfiguration.java @@ -16,11 +16,14 @@ package org.springframework.security.config.annotation.rsocket; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; +import org.springframework.security.authentication.ObservationReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; @@ -43,6 +46,8 @@ class RSocketSecurityConfiguration { private PasswordEncoder passwordEncoder; + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + @Autowired(required = false) void setAuthenticationManager(ReactiveAuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; @@ -58,6 +63,11 @@ void setPasswordEncoder(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } + @Autowired(required = false) + void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + @Bean(name = RSOCKET_SECURITY_BEAN_NAME) @Scope("prototype") RSocketSecurity rsocketSecurity(ApplicationContext context) { @@ -76,6 +86,9 @@ private ReactiveAuthenticationManager authenticationManager() { if (this.passwordEncoder != null) { manager.setPasswordEncoder(this.passwordEncoder); } + if (!this.observationRegistry.isNoop()) { + return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager); + } return manager; } return null; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 5f7535b20c5..13697dd0e78 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -34,6 +35,7 @@ import org.springframework.core.Ordered; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ObservationAuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.AbstractConfiguredSecurityBuilder; import org.springframework.security.config.annotation.ObjectPostProcessor; @@ -2994,7 +2996,14 @@ protected void beforeConfigure() throws Exception { setSharedObject(AuthenticationManager.class, this.authenticationManager); } else { - setSharedObject(AuthenticationManager.class, getAuthenticationRegistry().build()); + ObservationRegistry registry = getObservationRegistry(); + AuthenticationManager manager = getAuthenticationRegistry().build(); + if (!registry.isNoop()) { + setSharedObject(AuthenticationManager.class, new ObservationAuthenticationManager(registry, manager)); + } + else { + setSharedObject(AuthenticationManager.class, manager); + } } } @@ -3418,6 +3427,15 @@ private createAuthorizationManager() { + ". Try completing it with something like requestUrls()..hasRole('USER')"); Assert.state(this.mappingCount > 0, "At least one mapping is required (for example, authorizeHttpRequests().anyRequest().authenticated())"); - return postProcess(this.managerBuilder.build()); + ObservationRegistry registry = getObservationRegistry(); + RequestMatcherDelegatingAuthorizationManager manager = postProcess(this.managerBuilder.build()); + if (registry.isNoop()) { + return manager; + } + return new ObservationAuthorizationManager<>(registry, manager); } @Override diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java index 70648cca6f1..74b8337a4b7 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.web.reactive; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; @@ -27,6 +29,7 @@ import org.springframework.context.annotation.Scope; import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.security.authentication.ObservationReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.config.web.server.ServerHttpSecurity; @@ -60,6 +63,8 @@ class ServerHttpSecurityConfiguration { private ReactiveUserDetailsPasswordService userDetailsPasswordService; + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + @Autowired(required = false) private BeanFactory beanFactory; @@ -88,6 +93,11 @@ void setUserDetailsPasswordService(ReactiveUserDetailsPasswordService userDetail this.userDetailsPasswordService = userDetailsPasswordService; } + @Autowired(required = false) + void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + @Bean static WebFluxConfigurer authenticationPrincipalArgumentResolverConfigurer( ObjectProvider authenticationPrincipalArgumentResolver) { @@ -143,6 +153,9 @@ private ReactiveAuthenticationManager authenticationManager() { manager.setPasswordEncoder(this.passwordEncoder); } manager.setUserDetailsPasswordService(this.userDetailsPasswordService); + if (!this.observationRegistry.isNoop()) { + return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager); + } return manager; } return null; diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java index 8dfc4da381d..1e7b09fe6da 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; @@ -31,6 +33,7 @@ import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.SpringAuthorizationEventPublisher; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; @@ -68,8 +71,9 @@ final class WebSocketMessageBrokerSecurityConfiguration private final ChannelInterceptor csrfChannelInterceptor = new CsrfChannelInterceptor(); - private AuthorizationChannelInterceptor authorizationChannelInterceptor = new AuthorizationChannelInterceptor( - ANY_MESSAGE_AUTHENTICATED); + private AuthorizationManager> authorizationManager = ANY_MESSAGE_AUTHENTICATED; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; private ApplicationContext context; @@ -86,12 +90,15 @@ public void addArgumentResolvers(List argumentRes @Override public void configureClientInboundChannel(ChannelRegistration registration) { - this.authorizationChannelInterceptor - .setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); - this.authorizationChannelInterceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); + AuthorizationManager> manager = this.authorizationManager; + if (!this.observationRegistry.isNoop()) { + manager = new ObservationAuthorizationManager<>(this.observationRegistry, manager); + } + AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(manager); + interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context)); + interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); this.securityContextChannelInterceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); - registration.interceptors(this.securityContextChannelInterceptor, this.csrfChannelInterceptor, - this.authorizationChannelInterceptor); + registration.interceptors(this.securityContextChannelInterceptor, this.csrfChannelInterceptor, interceptor); } @Autowired(required = false) @@ -102,7 +109,12 @@ void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityCont @Autowired(required = false) void setAuthorizationManager(AuthorizationManager> authorizationManager) { - this.authorizationChannelInterceptor = new AuthorizationChannelInterceptor(authorizationManager); + this.authorizationManager = authorizationManager; + } + + @Autowired(required = false) + void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; } @Override diff --git a/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java b/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java index ce199e8ea3a..9e2b6a8a65d 100644 --- a/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java +++ b/config/src/main/java/org/springframework/security/config/authentication/AuthenticationManagerFactoryBean.java @@ -18,6 +18,8 @@ import java.util.Arrays; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; @@ -25,6 +27,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ObservationAuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.BeanIds; @@ -43,6 +46,8 @@ public class AuthenticationManagerFactoryBean implements FactoryBean elements)? Alternatively you can use the " + "authentication-manager-ref attribute on your and elements."; @@ -67,7 +72,11 @@ public AuthenticationManager getObject() throws Exception { provider.setPasswordEncoder(passwordEncoder); } provider.afterPropertiesSet(); - return new ProviderManager(Arrays.asList(provider)); + ProviderManager manager = new ProviderManager(Arrays.asList(provider)); + if (this.observationRegistry.isNoop()) { + return manager; + } + return new ObservationAuthenticationManager(this.observationRegistry, manager); } } @@ -86,6 +95,10 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.bf = beanFactory; } + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + private T getBeanOrNull(Class type) { try { return this.bf.getBean(type); diff --git a/config/src/main/java/org/springframework/security/config/http/AuthorizationFilterParser.java b/config/src/main/java/org/springframework/security/config/http/AuthorizationFilterParser.java index 2855966da27..3baac942048 100644 --- a/config/src/main/java/org/springframework/security/config/http/AuthorizationFilterParser.java +++ b/config/src/main/java/org/springframework/security/config/http/AuthorizationFilterParser.java @@ -19,10 +19,12 @@ import java.util.List; import java.util.Map; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.http.HttpServletRequest; import org.w3c.dom.Element; import org.springframework.beans.BeanMetadataElement; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.RuntimeBeanReference; import org.springframework.beans.factory.parsing.BeanComponentDefinition; @@ -34,6 +36,7 @@ import org.springframework.beans.factory.xml.XmlReaderContext; import org.springframework.security.authorization.AuthenticatedAuthorizationManager; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.config.Elements; import org.springframework.security.web.access.expression.DefaultHttpSecurityExpressionHandler; import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; @@ -51,6 +54,8 @@ class AuthorizationFilterParser implements BeanDefinitionParser { private static final String ATT_ACCESS_DECISION_MANAGER_REF = "access-decision-manager-ref"; + private static final String ATT_OBSERVATION_REGISTRY_REF = "observation-registry-ref"; + private static final String ATT_HTTP_METHOD = "method"; private static final String ATT_PATTERN = "pattern"; @@ -127,9 +132,9 @@ private String createAuthorizationManager(Element element, ParserContext parserC matcherToExpression.put(matcher, authorizationManager.getBeanDefinition()); } BeanDefinitionBuilder mds = BeanDefinitionBuilder - .rootBeanDefinition(RequestMatcherDelegatingAuthorizationManagerFactory.class); - mds.setFactoryMethod("createRequestMatcherDelegatingAuthorizationManager"); - mds.addConstructorArgValue(matcherToExpression); + .rootBeanDefinition(RequestMatcherDelegatingAuthorizationManagerFactory.class) + .addPropertyValue("requestMatcherMap", matcherToExpression) + .addPropertyValue("observationRegistry", getObservationRegistry(element)); return context.registerWithGeneratedName(mds.getBeanDefinition()); } @@ -169,17 +174,48 @@ boolean isUseExpressions(Element elt) { return !StringUtils.hasText(useExpressions) || "true".equals(useExpressions); } - private static class RequestMatcherDelegatingAuthorizationManagerFactory { + private BeanMetadataElement getObservationRegistry(Element methodSecurityElmt) { + String holderStrategyRef = methodSecurityElmt.getAttribute(ATT_OBSERVATION_REGISTRY_REF); + if (StringUtils.hasText(holderStrategyRef)) { + return new RuntimeBeanReference(holderStrategyRef); + } + return BeanDefinitionBuilder.rootBeanDefinition(ObservationRegistryFactory.class).getBeanDefinition(); + } + + public static final class RequestMatcherDelegatingAuthorizationManagerFactory + implements FactoryBean> { + + private Map> beans; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; - private static AuthorizationManager createRequestMatcherDelegatingAuthorizationManager( - Map> beans) { + @Override + public AuthorizationManager getObject() throws Exception { RequestMatcherDelegatingAuthorizationManager.Builder builder = RequestMatcherDelegatingAuthorizationManager .builder(); - for (Map.Entry> entry : beans + for (Map.Entry> entry : this.beans .entrySet()) { builder.add(entry.getKey(), entry.getValue()); } - return builder.add(AnyRequestMatcher.INSTANCE, AuthenticatedAuthorizationManager.authenticated()).build(); + AuthorizationManager manager = builder + .add(AnyRequestMatcher.INSTANCE, AuthenticatedAuthorizationManager.authenticated()).build(); + if (!this.observationRegistry.isNoop()) { + return new ObservationAuthorizationManager<>(this.observationRegistry, manager); + } + return manager; + } + + @Override + public Class getObjectType() { + return AuthorizationManager.class; + } + + public void setRequestMatcherMap(Map> beans) { + this.beans = beans; + } + + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; } } @@ -199,4 +235,18 @@ public DefaultHttpSecurityExpressionHandler getBean() { } + static class ObservationRegistryFactory implements FactoryBean { + + @Override + public ObservationRegistry getObject() throws Exception { + return ObservationRegistry.NOOP; + } + + @Override + public Class getObjectType() { + return ObservationRegistry.class; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java index 1dd528fbff2..44c6007c192 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java @@ -20,12 +20,14 @@ import java.util.Collections; import java.util.List; +import io.micrometer.observation.ObservationRegistry; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.w3c.dom.Element; import org.springframework.beans.BeanMetadataElement; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanReference; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -43,8 +45,11 @@ import org.springframework.beans.factory.xml.BeanDefinitionParser; import org.springframework.beans.factory.xml.ParserContext; import org.springframework.core.OrderComparator; +import org.springframework.security.authentication.AuthenticationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; +import org.springframework.security.authentication.ObservationAuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.BeanIds; import org.springframework.security.config.Elements; @@ -70,6 +75,8 @@ public class HttpSecurityBeanDefinitionParser implements BeanDefinitionParser { private static final String ATT_AUTHENTICATION_MANAGER_REF = "authentication-manager-ref"; + private static final String ATT_OBSERVATION_REGISTRY_REF = "observation-registry-ref"; + static final String ATT_REQUEST_MATCHER_REF = "request-matcher-ref"; static final String ATT_PATH_PATTERN = "pattern"; @@ -246,7 +253,8 @@ private RuntimeBeanReference createPortResolver(BeanReference portMapper, Parser private BeanReference createAuthenticationManager(Element element, ParserContext pc, ManagedList authenticationProviders) { String parentMgrRef = element.getAttribute(ATT_AUTHENTICATION_MANAGER_REF); - BeanDefinitionBuilder authManager = BeanDefinitionBuilder.rootBeanDefinition(ProviderManager.class); + BeanDefinitionBuilder authManager = BeanDefinitionBuilder + .rootBeanDefinition(ChildAuthenticationManagerFactoryBean.class); authManager.addConstructorArgValue(authenticationProviders); if (StringUtils.hasText(parentMgrRef)) { RuntimeBeanReference parentAuthManager = new RuntimeBeanReference(parentMgrRef); @@ -273,6 +281,7 @@ private BeanReference createAuthenticationManager(Element element, ParserContext // gh-6009 authManager.addPropertyValue("authenticationEventPublisher", new RootBeanDefinition(DefaultAuthenticationEventPublisher.class)); + authManager.addPropertyValue("observationRegistry", getObservationRegistry(element)); authManager.getRawBeanDefinition().setSource(pc.extractSource(element)); BeanDefinition authMgrBean = authManager.getBeanDefinition(); String id = pc.getReaderContext().generateBeanName(authMgrBean); @@ -368,6 +377,14 @@ static void registerFilterChainProxyIfNecessary(ParserContext pc, Object source) registry.registerBeanDefinition(requestRejectedPostProcessorName, requestRejectedBean); } + private static BeanMetadataElement getObservationRegistry(Element methodSecurityElmt) { + String holderStrategyRef = methodSecurityElmt.getAttribute(ATT_OBSERVATION_REGISTRY_REF); + if (StringUtils.hasText(holderStrategyRef)) { + return new RuntimeBeanReference(holderStrategyRef); + } + return BeanDefinitionBuilder.rootBeanDefinition(ObservationRegistryFactory.class).getBeanDefinition(); + } + static class RequestRejectedHandlerPostProcessor implements BeanDefinitionRegistryPostProcessor { private final String beanName; @@ -434,4 +451,62 @@ boolean isEraseCredentialsAfterAuthentication() { } + public static final class ChildAuthenticationManagerFactoryBean implements FactoryBean { + + private final ProviderManager delegate; + + private AuthenticationEventPublisher authenticationEventPublisher = new DefaultAuthenticationEventPublisher(); + + private boolean eraseCredentialsAfterAuthentication = true; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + public ChildAuthenticationManagerFactoryBean(List providers, + AuthenticationManager parent) { + this.delegate = new ProviderManager(providers, parent); + } + + @Override + public AuthenticationManager getObject() throws Exception { + this.delegate.setAuthenticationEventPublisher(this.authenticationEventPublisher); + this.delegate.setEraseCredentialsAfterAuthentication(this.eraseCredentialsAfterAuthentication); + if (!this.observationRegistry.isNoop()) { + return new ObservationAuthenticationManager(this.observationRegistry, this.delegate); + } + return this.delegate; + } + + @Override + public Class getObjectType() { + return AuthenticationManager.class; + } + + public void setEraseCredentialsAfterAuthentication(boolean eraseCredentialsAfterAuthentication) { + this.eraseCredentialsAfterAuthentication = eraseCredentialsAfterAuthentication; + } + + public void setAuthenticationEventPublisher(AuthenticationEventPublisher authenticationEventPublisher) { + this.authenticationEventPublisher = authenticationEventPublisher; + } + + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + + } + + static class ObservationRegistryFactory implements FactoryBean { + + @Override + public ObservationRegistry getObject() throws Exception { + return ObservationRegistry.NOOP; + } + + @Override + public Class getObjectType() { + return ObservationRegistry.class; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/method/MethodSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/method/MethodSecurityBeanDefinitionParser.java index 63f36145362..c1bedb3471f 100644 --- a/config/src/main/java/org/springframework/security/config/method/MethodSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/method/MethodSecurityBeanDefinitionParser.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import io.micrometer.observation.ObservationRegistry; +import org.aopalliance.intercept.MethodInvocation; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.w3c.dom.Element; @@ -42,14 +44,18 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.ObservationAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.authorization.method.Jsr250AuthorizationManager; import org.springframework.security.authorization.method.MethodExpressionAuthorizationManager; +import org.springframework.security.authorization.method.MethodInvocationResult; import org.springframework.security.authorization.method.PostAuthorizeAuthorizationManager; import org.springframework.security.authorization.method.PostFilterAuthorizationMethodInterceptor; import org.springframework.security.authorization.method.PreAuthorizeAuthorizationManager; import org.springframework.security.authorization.method.PreFilterAuthorizationMethodInterceptor; +import org.springframework.security.authorization.method.SecuredAuthorizationManager; import org.springframework.security.config.Elements; import org.springframework.security.config.core.GrantedAuthorityDefaults; import org.springframework.security.core.context.SecurityContextHolder; @@ -75,6 +81,8 @@ public class MethodSecurityBeanDefinitionParser implements BeanDefinitionParser private static final String ATT_AUTHORIZATION_MGR = "authorization-manager-ref"; + private static final String ATT_OBSERVATION_REGISTRY_REF = "observation-registry-ref"; + private static final String ATT_ACCESS = "access"; private static final String ATT_EXPRESSION = "expression"; @@ -89,6 +97,7 @@ public BeanDefinition parse(Element element, ParserContext pc) { pc.extractSource(element)); pc.pushContainingComponent(compositeDef); BeanMetadataElement securityContextHolderStrategy = getSecurityContextHolderStrategy(element); + BeanMetadataElement observationRegistry = getObservationRegistry(element); boolean prePostAnnotationsEnabled = !element.hasAttribute(ATT_USE_PREPOST) || "true".equals(element.getAttribute(ATT_USE_PREPOST)); boolean useAspectJ = "aspectj".equals(element.getAttribute(ATT_MODE)); @@ -100,11 +109,13 @@ public BeanDefinition parse(Element element, ParserContext pc) { BeanDefinitionBuilder preAuthorizeInterceptor = BeanDefinitionBuilder .rootBeanDefinition(PreAuthorizeAuthorizationMethodInterceptor.class) .setRole(BeanDefinition.ROLE_INFRASTRUCTURE) - .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy); + .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy) + .addPropertyValue("observationRegistry", observationRegistry); BeanDefinitionBuilder postAuthorizeInterceptor = BeanDefinitionBuilder .rootBeanDefinition(PostAuthorizeAuthorizationMethodInterceptor.class) .setRole(BeanDefinition.ROLE_INFRASTRUCTURE) - .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy); + .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy) + .addPropertyValue("observationRegistry", observationRegistry); BeanDefinitionBuilder postFilterInterceptor = BeanDefinitionBuilder .rootBeanDefinition(PostFilterAuthorizationMethodInterceptor.class) .setRole(BeanDefinition.ROLE_INFRASTRUCTURE) @@ -137,10 +148,10 @@ public BeanDefinition parse(Element element, ParserContext pc) { boolean securedEnabled = "true".equals(element.getAttribute(ATT_USE_SECURED)); if (securedEnabled) { BeanDefinitionBuilder securedInterceptor = BeanDefinitionBuilder - .rootBeanDefinition(AuthorizationManagerBeforeMethodInterceptor.class) + .rootBeanDefinition(SecuredAuthorizationMethodInterceptor.class) .setRole(BeanDefinition.ROLE_INFRASTRUCTURE) .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy) - .setFactoryMethod("secured"); + .addPropertyValue("observationRegistry", observationRegistry); pc.getRegistry().registerBeanDefinition("securedAuthorizationMethodInterceptor", securedInterceptor.getBeanDefinition()); } @@ -149,7 +160,8 @@ public BeanDefinition parse(Element element, ParserContext pc) { BeanDefinitionBuilder jsr250Interceptor = BeanDefinitionBuilder .rootBeanDefinition(Jsr250AuthorizationMethodInterceptor.class) .setRole(BeanDefinition.ROLE_INFRASTRUCTURE) - .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy); + .addPropertyValue("securityContextHolderStrategy", securityContextHolderStrategy) + .addPropertyValue("observationRegistry", observationRegistry); pc.getRegistry().registerBeanDefinition("jsr250AuthorizationMethodInterceptor", jsr250Interceptor.getBeanDefinition()); } @@ -182,6 +194,14 @@ public BeanDefinition parse(Element element, ParserContext pc) { return null; } + private BeanMetadataElement getObservationRegistry(Element methodSecurityElmt) { + String holderStrategyRef = methodSecurityElmt.getAttribute(ATT_OBSERVATION_REGISTRY_REF); + if (StringUtils.hasText(holderStrategyRef)) { + return new RuntimeBeanReference(holderStrategyRef); + } + return BeanDefinitionBuilder.rootBeanDefinition(ObservationRegistryFactory.class).getBeanDefinition(); + } + private BeanMetadataElement getSecurityContextHolderStrategy(Element methodSecurityElmt) { String holderStrategyRef = methodSecurityElmt.getAttribute(ATT_SECURITY_CONTEXT_HOLDER_STRATEGY_REF); if (StringUtils.hasText(holderStrategyRef)) { @@ -295,12 +315,18 @@ public static final class Jsr250AuthorizationMethodInterceptor private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder .getContextHolderStrategy(); + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + private final Jsr250AuthorizationManager manager = new Jsr250AuthorizationManager(); @Override public AuthorizationManagerBeforeMethodInterceptor getObject() { + AuthorizationManager manager = this.manager; + if (!this.observationRegistry.isNoop()) { + manager = new ObservationAuthorizationManager<>(this.observationRegistry, this.manager); + } AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor - .jsr250(this.manager); + .jsr250(manager); interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); return interceptor; } @@ -325,6 +351,47 @@ public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy secur this.securityContextHolderStrategy = securityContextHolderStrategy; } + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + + } + + public static final class SecuredAuthorizationMethodInterceptor + implements FactoryBean { + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + private final SecuredAuthorizationManager manager = new SecuredAuthorizationManager(); + + @Override + public AuthorizationManagerBeforeMethodInterceptor getObject() { + AuthorizationManager manager = this.manager; + if (!this.observationRegistry.isNoop()) { + manager = new ObservationAuthorizationManager<>(this.observationRegistry, this.manager); + } + AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor + .secured(manager); + interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); + return interceptor; + } + + @Override + public Class getObjectType() { + return AuthorizationManagerBeforeMethodInterceptor.class; + } + + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + this.securityContextHolderStrategy = securityContextHolderStrategy; + } + + public void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + } public static final class PreAuthorizeAuthorizationMethodInterceptor @@ -333,12 +400,18 @@ public static final class PreAuthorizeAuthorizationMethodInterceptor private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder .getContextHolderStrategy(); + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + private final PreAuthorizeAuthorizationManager manager = new PreAuthorizeAuthorizationManager(); @Override public AuthorizationManagerBeforeMethodInterceptor getObject() { + AuthorizationManager manager = this.manager; + if (!this.observationRegistry.isNoop()) { + manager = new ObservationAuthorizationManager<>(this.observationRegistry, this.manager); + } AuthorizationManagerBeforeMethodInterceptor interceptor = AuthorizationManagerBeforeMethodInterceptor - .preAuthorize(this.manager); + .preAuthorize(manager); interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); return interceptor; } @@ -356,6 +429,10 @@ public void setExpressionHandler(MethodSecurityExpressionHandler expressionHandl this.manager.setExpressionHandler(expressionHandler); } + public void setObservationRegistry(ObservationRegistry registry) { + this.observationRegistry = registry; + } + } public static final class PostAuthorizeAuthorizationMethodInterceptor @@ -364,12 +441,18 @@ public static final class PostAuthorizeAuthorizationMethodInterceptor private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder .getContextHolderStrategy(); + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + private final PostAuthorizeAuthorizationManager manager = new PostAuthorizeAuthorizationManager(); @Override public AuthorizationManagerAfterMethodInterceptor getObject() { + AuthorizationManager manager = this.manager; + if (!this.observationRegistry.isNoop()) { + manager = new ObservationAuthorizationManager<>(this.observationRegistry, this.manager); + } AuthorizationManagerAfterMethodInterceptor interceptor = AuthorizationManagerAfterMethodInterceptor - .postAuthorize(this.manager); + .postAuthorize(manager); interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); return interceptor; } @@ -387,6 +470,10 @@ public void setExpressionHandler(MethodSecurityExpressionHandler expressionHandl this.manager.setExpressionHandler(expressionHandler); } + public void setObservationRegistry(ObservationRegistry registry) { + this.observationRegistry = registry; + } + } static class SecurityContextHolderStrategyFactory implements FactoryBean { @@ -403,4 +490,18 @@ public Class getObjectType() { } + static class ObservationRegistryFactory implements FactoryBean { + + @Override + public ObservationRegistry getObject() throws Exception { + return ObservationRegistry.NOOP; + } + + @Override + public Class getObjectType() { + return ObservationRegistry.class; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 994a48e321c..16e232a76e9 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -31,6 +31,7 @@ import java.util.function.Function; import java.util.function.Supplier; +import io.micrometer.observation.ObservationRegistry; import reactor.core.publisher.Mono; import reactor.util.context.Context; @@ -50,6 +51,7 @@ import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager; import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager; import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.config.Customizer; import org.springframework.security.core.Authentication; @@ -1549,6 +1551,14 @@ private T getBean(Class beanClass) { return this.context.getBean(beanClass); } + private T getBeanOrDefault(Class beanClass, T defaultInstance) { + T bean = getBeanOrNull(beanClass); + if (bean == null) { + return defaultInstance; + } + return bean; + } + private T getBeanOrNull(Class beanClass) { return getBeanOrNull(ResolvableType.forClass(beanClass)); } @@ -1623,7 +1633,12 @@ protected Access registerMatcher(ServerWebExchangeMatcher matcher) { protected void configure(ServerHttpSecurity http) { Assert.state(this.matcher == null, () -> "The matcher " + this.matcher + " does not have an access rule defined"); - AuthorizationWebFilter result = new AuthorizationWebFilter(this.managerBldr.build()); + ReactiveAuthorizationManager manager = this.managerBldr.build(); + ObservationRegistry registry = getBeanOrDefault(ObservationRegistry.class, ObservationRegistry.NOOP); + if (!registry.isNoop()) { + manager = new ObservationReactiveAuthorizationManager<>(registry, manager); + } + AuthorizationWebFilter result = new AuthorizationWebFilter(manager); http.addFilterAt(result, SecurityWebFiltersOrder.AUTHORIZATION); } diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc index 786b0777d73..7f89ced5afd 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.rnc @@ -222,6 +222,9 @@ method-security.attlist &= method-security.attlist &= ## Specifies the security context holder strategy to use, by default uses a ThreadLocal-based strategy attribute security-context-holder-strategy-ref {xsd:string}? +method-security.attlist &= + ## Use this ObservationRegistry to collect metrics on various parts of the filter chain + attribute observation-registry-ref {xsd:token}? global-method-security = ## Provides method security for all beans registered in the Spring application context. Specifically, beans will be scanned for matches with the ordered list of "protect-pointcut" sub-elements, Spring Security annotations and/or. Where there is a match, the beans will automatically be proxied and security authorization applied to the methods accordingly. If you use and enable all four sources of method security metadata (ie "protect-pointcut" declarations, expression annotations, @Secured and also JSR250 security annotations), the metadata sources will be queried in that order. In practical terms, this enables you to use XML to override method security metadata expressed in annotations. If using annotations, the order of precedence is EL-based (@PreAuthorize etc.), @Secured and finally JSR-250. @@ -396,6 +399,9 @@ http.attlist &= name? http.attlist &= authentication-manager-ref? +http.attlist &= + ## Use this ObservationRegistry to collect metrics on various parts of the filter chain + attribute observation-registry-ref {xsd:token}? access-denied-handler = ## Defines the access-denied strategy that should be used. An access denied page can be defined or a reference to an AccessDeniedHandler instance. @@ -1057,6 +1063,9 @@ authman.attlist &= authman.attlist &= ## If set to true, the AuthenticationManger will attempt to clear any credentials data in the returned Authentication object, once the user has been authenticated. attribute erase-credentials {xsd:boolean}? +authman.attlist &= + ## Use this ObservationRegistry to collect metrics on various parts of the filter chain + attribute observation-registry-ref {xsd:token}? authentication-provider = ## Indicates that the contained user-service should be used as an authentication source. diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd index 32ca4c6192d..f123ad830a7 100644 --- a/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd +++ b/config/src/main/resources/org/springframework/security/config/spring-security-6.0.xsd @@ -695,6 +695,12 @@ + + + Use this ObservationRegistry to collect metrics on various parts of the filter chain + + + @@ -1389,6 +1395,12 @@ + + + Use this ObservationRegistry to collect metrics on various parts of the filter chain + + + @@ -2990,6 +3002,12 @@ + + + Use this ObservationRegistry to collect metrics on various parts of the filter chain + + + diff --git a/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationContext.java b/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationContext.java new file mode 100644 index 00000000000..7756506a08a --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationContext.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.observation.Observation; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Observation.Context} used during authentications + * + * @author Josh Cummings + * @since 6.0 + */ +public class AuthenticationObservationContext extends Observation.Context { + + private Authentication authenticationRequest; + + private Class authenticationManager; + + private Authentication authenticationResult; + + /** + * Get the {@link Authentication} request that was observed + * @return the observed {@link Authentication} request + */ + public Authentication getAuthenticationRequest() { + return this.authenticationRequest; + } + + /** + * Set the {@link Authentication} request that was observed + * @param authenticationRequest the observed {@link Authentication} request + */ + public void setAuthenticationRequest(Authentication authenticationRequest) { + Assert.notNull(authenticationRequest, "authenticationRequest cannot be null"); + this.authenticationRequest = authenticationRequest; + } + + /** + * Get the {@link Authentication} result that was observed + * + *

+ * Note that if authentication failed, no {@link Authentication} result can be + * observed. In that case, this returns {@code null}. + * @return any observed {@link Authentication} result, {@code null} otherwise + */ + public Authentication getAuthenticationResult() { + return this.authenticationResult; + } + + /** + * Set the {@link Authentication} result that was observed + * @param authenticationResult the observed {@link Authentication} result + */ + public void setAuthenticationResult(Authentication authenticationResult) { + this.authenticationResult = authenticationResult; + } + + /** + * Get the {@link AuthenticationManager} class that processed the authentication + * @return the observed {@link AuthenticationManager} class + */ + public Class getAuthenticationManagerClass() { + return this.authenticationManager; + } + + /** + * Set the {@link AuthenticationManager} class that processed the authentication + * @param authenticationManagerClass the observed {@link AuthenticationManager} class + */ + public void setAuthenticationManagerClass(Class authenticationManagerClass) { + Assert.notNull(authenticationManagerClass, "authenticationManagerClass class cannot be null"); + this.authenticationManager = authenticationManagerClass; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationConvention.java b/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationConvention.java new file mode 100644 index 00000000000..0d229ad05b7 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/AuthenticationObservationConvention.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import org.jetbrains.annotations.NotNull; + +import org.springframework.lang.NonNull; + +/** + * An {@link ObservationConvention} for translating authentications into + * {@link KeyValues}. + * + * @author Josh Cummings + * @since 6.0 + */ +public final class AuthenticationObservationConvention + implements ObservationConvention { + + static final String OBSERVATION_NAME = "spring.security.authentications"; + + /** + * {@inheritDoc} + */ + @Override + public String getName() { + return OBSERVATION_NAME; + } + + /** + * {@inheritDoc} + */ + @NotNull + @Override + public KeyValues getLowCardinalityKeyValues(@NonNull AuthenticationObservationContext context) { + return KeyValues.of("authentication.request.type", getAuthenticationType(context)) + .and("authentication.method", getAuthenticationMethod(context)) + .and("authentication.result.type", getAuthenticationResult(context)) + .and("authentication.failure.type", getAuthenticationFailureType(context)); + } + + private String getAuthenticationType(AuthenticationObservationContext context) { + if (context.getAuthenticationRequest() == null) { + return "unknown"; + } + return context.getAuthenticationRequest().getClass().getSimpleName(); + } + + private String getAuthenticationMethod(AuthenticationObservationContext context) { + if (context.getAuthenticationManagerClass() == null) { + return "unknown"; + } + return context.getAuthenticationManagerClass().getSimpleName(); + } + + private String getAuthenticationResult(AuthenticationObservationContext context) { + if (context.getAuthenticationResult() == null) { + return "n/a"; + } + return context.getAuthenticationResult().getClass().getSimpleName(); + } + + private String getAuthenticationFailureType(AuthenticationObservationContext context) { + if (context.getError() == null) { + return "n/a"; + } + return context.getError().getClass().getSimpleName(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supportsContext(@NotNull Observation.Context context) { + return context instanceof AuthenticationObservationContext; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ObservationAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/ObservationAuthenticationManager.java new file mode 100644 index 00000000000..377761be2b7 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ObservationAuthenticationManager.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationManager} that observes the authentication + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationAuthenticationManager implements AuthenticationManager { + + private final ObservationRegistry registry; + + private final AuthenticationManager delegate; + + private final AuthenticationObservationConvention convention = new AuthenticationObservationConvention(); + + public ObservationAuthenticationManager(ObservationRegistry registry, AuthenticationManager delegate) { + Assert.notNull(registry, "observationRegistry cannot be null"); + Assert.notNull(delegate, "authenticationManager cannot be null"); + this.registry = registry; + this.delegate = delegate; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + AuthenticationObservationContext context = new AuthenticationObservationContext(); + context.setAuthenticationRequest(authentication); + context.setAuthenticationManagerClass(this.delegate.getClass()); + return Observation.createNotStarted(this.convention, () -> context, this.registry).observe(() -> { + Authentication result = this.delegate.authenticate(authentication); + context.setAuthenticationResult(result); + return result; + }); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManager.java new file mode 100644 index 00000000000..6ae124e39cf --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManager.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * An {@link ReactiveAuthenticationManager} that observes the authentication + * + * @author Josh Cummings + * @since 6.0 + */ +public class ObservationReactiveAuthenticationManager implements ReactiveAuthenticationManager { + + private final ObservationRegistry registry; + + private final ReactiveAuthenticationManager delegate; + + private final AuthenticationObservationConvention convention = new AuthenticationObservationConvention(); + + public ObservationReactiveAuthenticationManager(ObservationRegistry registry, + ReactiveAuthenticationManager delegate) { + this.registry = registry; + this.delegate = delegate; + } + + @Override + public Mono authenticate(Authentication authentication) throws AuthenticationException { + AuthenticationObservationContext context = new AuthenticationObservationContext(); + context.setAuthenticationRequest(authentication); + context.setAuthenticationManagerClass(this.delegate.getClass()); + Observation observation = Observation.createNotStarted(this.convention, () -> context, this.registry).start(); + return this.delegate.authenticate(authentication).doOnSuccess((result) -> { + context.setAuthenticationResult(result); + observation.stop(); + }).doOnCancel(observation::stop).doOnError((t) -> { + observation.error(t); + observation.stop(); + }); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationContext.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationContext.java new file mode 100644 index 00000000000..8e5692213c0 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationContext.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import io.micrometer.observation.Observation; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Observation.Context} used during authorizations + * + * @author Josh Cummings + * @since 6.0 + */ +public class AuthorizationObservationContext extends Observation.Context { + + private Authentication authentication; + + private final T object; + + private AuthorizationDecision decision; + + public AuthorizationObservationContext(T object) { + Assert.notNull(object, "object cannot be null"); + this.object = object; + } + + /** + * Get the observed {@link Authentication} for this authorization + * + *

+ * Note that if the authorization did not require inspecting the + * {@link Authentication}, this will return {@code null}. + * @return any observed {@link Authentication}, {@code null} otherwise + */ + public Authentication getAuthentication() { + return this.authentication; + } + + /** + * Set the observed {@link Authentication} for this authorization + * @param authentication the observed {@link Authentication} + */ + public void setAuthentication(Authentication authentication) { + this.authentication = authentication; + } + + /** + * Get the object for which access was requested + * @return the requested object + */ + public T getObject() { + return this.object; + } + + /** + * Get the observed {@link AuthorizationDecision} + * @return the observed {@link AuthorizationDecision} + */ + public AuthorizationDecision getDecision() { + return this.decision; + } + + /** + * Set the observed {@link AuthorizationDecision} + * @param decision the observed {@link AuthorizationDecision} + */ + public void setDecision(AuthorizationDecision decision) { + this.decision = decision; + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationConvention.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationConvention.java new file mode 100644 index 00000000000..8e2f6f8499e --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationObservationConvention.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * An {@link ObservationConvention} for translating authorizations into {@link KeyValues}. + * + * @author Josh Cummings + * @since 6.0 + */ +public final class AuthorizationObservationConvention + implements ObservationConvention> { + + static final String OBSERVATION_NAME = "spring.security.authorizations"; + + /** + * {@inheritDoc} + */ + @Override + public String getName() { + return OBSERVATION_NAME; + } + + /** + * {@inheritDoc} + */ + @Override + public KeyValues getLowCardinalityKeyValues(AuthorizationObservationContext context) { + return KeyValues.of("authentication.type", getAuthenticationType(context)) + .and("object.type", getObjectType(context)) + .and("authorization.decision", getAuthorizationDecision(context)); + } + + /** + * {@inheritDoc} + */ + @Override + public KeyValues getHighCardinalityKeyValues(AuthorizationObservationContext context) { + return KeyValues.of("authentication.authorities", getAuthorities(context)).and("authorization.decision.details", + getDecisionDetails(context)); + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof AuthorizationObservationContext; + } + + private String getAuthenticationType(AuthorizationObservationContext context) { + if (context.getAuthentication() == null) { + return "n/a"; + } + return context.getAuthentication().getClass().getSimpleName(); + } + + private String getObjectType(AuthorizationObservationContext context) { + if (context.getObject() == null) { + return "unknown"; + } + return context.getObject().getClass().getSimpleName(); + } + + private String getAuthorizationDecision(AuthorizationObservationContext context) { + if (context.getDecision() == null) { + return "unknown"; + } + return String.valueOf(context.getDecision().isGranted()); + } + + private String getAuthorities(AuthorizationObservationContext context) { + if (context.getAuthentication() == null) { + return "n/a"; + } + return String.valueOf(context.getAuthentication().getAuthorities()); + } + + private String getDecisionDetails(AuthorizationObservationContext context) { + if (context.getDecision() == null) { + return "unknown"; + } + AuthorizationDecision decision = context.getDecision(); + return String.valueOf(decision); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/ObservationAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/ObservationAuthorizationManager.java new file mode 100644 index 00000000000..343762e0acc --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ObservationAuthorizationManager.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import java.util.function.Supplier; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; + +/** + * An {@link AuthorizationManager} that observes the authorization + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationAuthorizationManager implements AuthorizationManager { + + private final ObservationRegistry registry; + + private final AuthorizationManager delegate; + + private final AuthorizationObservationConvention convention = new AuthorizationObservationConvention(); + + public ObservationAuthorizationManager(ObservationRegistry registry, AuthorizationManager delegate) { + this.registry = registry; + this.delegate = delegate; + } + + @Override + public AuthorizationDecision check(Supplier authentication, T object) { + AuthorizationObservationContext context = new AuthorizationObservationContext<>(object); + Supplier wrapped = () -> { + context.setAuthentication(authentication.get()); + return context.getAuthentication(); + }; + Observation observation = Observation.createNotStarted(this.convention, () -> context, this.registry).start(); + try (Observation.Scope scope = observation.openScope()) { + AuthorizationDecision decision = this.delegate.check(wrapped, object); + context.setDecision(decision); + if (decision != null && !decision.isGranted()) { + observation.error(new AccessDeniedException("Access Denied")); + } + return decision; + } + catch (Throwable ex) { + observation.error(ex); + throw ex; + } + finally { + observation.stop(); + } + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManager.java new file mode 100644 index 00000000000..ada6ff42705 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManager.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import reactor.core.publisher.Mono; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; + +/** + * An {@link ReactiveAuthorizationManager} that observes the authentication + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationReactiveAuthorizationManager implements ReactiveAuthorizationManager { + + private final ObservationRegistry registry; + + private final ReactiveAuthorizationManager delegate; + + private final AuthorizationObservationConvention convention = new AuthorizationObservationConvention(); + + public ObservationReactiveAuthorizationManager(ObservationRegistry registry, + ReactiveAuthorizationManager delegate) { + this.registry = registry; + this.delegate = delegate; + } + + @Override + public Mono check(Mono authentication, T object) { + AuthorizationObservationContext context = new AuthorizationObservationContext<>(object); + Mono wrapped = authentication.map((auth) -> { + context.setAuthentication(auth); + return context.getAuthentication(); + }); + Observation observation = Observation.createNotStarted(this.convention, () -> context, this.registry).start(); + return this.delegate.check(wrapped, object).doOnSuccess((decision) -> { + context.setDecision(decision); + if (decision == null || !decision.isGranted()) { + observation.error(new AccessDeniedException("Access Denied")); + } + observation.stop(); + }).doOnCancel(observation::stop).doOnError((t) -> { + observation.error(t); + observation.stop(); + }); + } + +} diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java index fcdad71b1c9..7728149a719 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java @@ -98,6 +98,20 @@ public static AuthorizationManagerAfterMethodInterceptor postAuthorize( return interceptor; } + /** + * Creates an interceptor for the {@link PostAuthorize} annotation + * @param authorizationManager the {@link AuthorizationManager} to use + * @return the interceptor + * @since 6.0 + */ + public static AuthorizationManagerAfterMethodInterceptor postAuthorize( + AuthorizationManager authorizationManager) { + AuthorizationManagerAfterMethodInterceptor interceptor = new AuthorizationManagerAfterMethodInterceptor( + AuthorizationMethodPointcuts.forAnnotations(PostAuthorize.class), authorizationManager); + interceptor.setOrder(500); + return interceptor; + } + /** * Determine if an {@link Authentication} has access to the {@link MethodInvocation} * using the {@link AuthorizationManager}. diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java index 08aae6aca91..955e3eb434f 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java @@ -102,6 +102,20 @@ public static AuthorizationManagerBeforeMethodInterceptor preAuthorize( return interceptor; } + /** + * Creates an interceptor for the {@link PreAuthorize} annotation + * @param authorizationManager the {@link AuthorizationManager} to use + * @return the interceptor + * @since 6.0 + */ + public static AuthorizationManagerBeforeMethodInterceptor preAuthorize( + AuthorizationManager authorizationManager) { + AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor( + AuthorizationMethodPointcuts.forAnnotations(PreAuthorize.class), authorizationManager); + interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder()); + return interceptor; + } + /** * Creates an interceptor for the {@link Secured} annotation * @return the interceptor @@ -123,6 +137,20 @@ public static AuthorizationManagerBeforeMethodInterceptor secured( return interceptor; } + /** + * Creates an interceptor for the {@link Secured} annotation + * @param authorizationManager the {@link AuthorizationManager} to use + * @return the interceptor + * @since 6.0 + */ + public static AuthorizationManagerBeforeMethodInterceptor secured( + AuthorizationManager authorizationManager) { + AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor( + AuthorizationMethodPointcuts.forAnnotations(Secured.class), authorizationManager); + interceptor.setOrder(AuthorizationInterceptorsOrder.SECURED.getOrder()); + return interceptor; + } + /** * Creates an interceptor for the JSR-250 annotations * @return the interceptor @@ -144,6 +172,21 @@ public static AuthorizationManagerBeforeMethodInterceptor jsr250(Jsr250Authoriza return interceptor; } + /** + * Creates an interceptor for the JSR-250 annotations + * @param authorizationManager the {@link AuthorizationManager} to use + * @return the interceptor + * @since 6.0 + */ + public static AuthorizationManagerBeforeMethodInterceptor jsr250( + AuthorizationManager authorizationManager) { + AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor( + AuthorizationMethodPointcuts.forAnnotations(RolesAllowed.class, DenyAll.class, PermitAll.class), + authorizationManager); + interceptor.setOrder(AuthorizationInterceptorsOrder.JSR250.getOrder()); + return interceptor; + } + /** * Determine if an {@link Authentication} has access to the {@link MethodInvocation} * using the configured {@link AuthorizationManager}. diff --git a/core/src/test/java/org/springframework/security/authentication/ObservationAuthenticationManagerTests.java b/core/src/test/java/org/springframework/security/authentication/ObservationAuthenticationManagerTests.java new file mode 100644 index 00000000000..4583d617461 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ObservationAuthenticationManagerTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ObservationAuthenticationManager} + */ +public class ObservationAuthenticationManagerTests { + + private ObservationRegistry registry; + + private ObservationHandler handler; + + private AuthenticationManager authenticationManager; + + private ObservationAuthenticationManager tested; + + private final Authentication token = new TestingAuthenticationToken("user", "pass"); + + private final Authentication authentication = new TestingAuthenticationToken("user", "pass", "app"); + + @BeforeEach + void setup() { + this.handler = mock(ObservationHandler.class); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + this.registry = registry; + this.authenticationManager = mock(AuthenticationManager.class); + this.tested = new ObservationAuthenticationManager(this.registry, this.authenticationManager); + } + + @Test + void authenticateWhenDefaultsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authenticationManager.authenticate(any())).willReturn(this.authentication); + this.tested.authenticate(this.token); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthenticationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + assertThat(captor.getValue()).isInstanceOf(AuthenticationObservationContext.class); + AuthenticationObservationContext context = (AuthenticationObservationContext) captor.getValue(); + assertThat(context.getAuthenticationManagerClass()).isEqualTo(this.authenticationManager.getClass()); + assertThat(context.getAuthenticationRequest()).isEqualTo(this.token); + assertThat(context.getAuthenticationResult()).isEqualTo(this.authentication); + + } + + @Test + void authenticationWhenErrorsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authenticationManager.authenticate(any())).willThrow(BadCredentialsException.class); + assertThatExceptionOfType(BadCredentialsException.class).isThrownBy(() -> this.tested.authenticate(this.token)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthenticationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isInstanceOf(AuthenticationException.class); + assertThat(captor.getValue()).isInstanceOf(AuthenticationObservationContext.class); + AuthenticationObservationContext context = (AuthenticationObservationContext) captor.getValue(); + assertThat(context.getAuthenticationManagerClass()).isEqualTo(this.authenticationManager.getClass()); + assertThat(context.getAuthenticationRequest()).isEqualTo(this.token); + assertThat(context.getAuthenticationResult()).isNull(); + } + +} diff --git a/core/src/test/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManagerTests.java b/core/src/test/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManagerTests.java new file mode 100644 index 00000000000..e9da7a7024d --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ObservationReactiveAuthenticationManagerTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authentication; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ObservationAuthenticationManager} + */ +public class ObservationReactiveAuthenticationManagerTests { + + private ObservationRegistry registry; + + private ObservationHandler handler; + + private ReactiveAuthenticationManager authenticationManager; + + private ObservationReactiveAuthenticationManager tested; + + private final Authentication token = new TestingAuthenticationToken("user", "pass"); + + private final Authentication authentication = new TestingAuthenticationToken("user", "pass", "app"); + + @BeforeEach + void setup() { + this.handler = mock(ObservationHandler.class); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + this.registry = registry; + this.authenticationManager = mock(ReactiveAuthenticationManager.class); + this.tested = new ObservationReactiveAuthenticationManager(this.registry, this.authenticationManager); + } + + @Test + void authenticateWhenDefaultsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authenticationManager.authenticate(any())).willReturn(Mono.just(this.authentication)); + this.tested.authenticate(this.token).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthenticationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + assertThat(captor.getValue()).isInstanceOf(AuthenticationObservationContext.class); + AuthenticationObservationContext context = (AuthenticationObservationContext) captor.getValue(); + assertThat(context.getAuthenticationManagerClass()).isEqualTo(this.authenticationManager.getClass()); + assertThat(context.getAuthenticationRequest()).isEqualTo(this.token); + assertThat(context.getAuthenticationResult()).isEqualTo(this.authentication); + + } + + @Test + void authenticationWhenErrorsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authenticationManager.authenticate(any())) + .willReturn(Mono.error(new BadCredentialsException("fail"))); + assertThatExceptionOfType(BadCredentialsException.class) + .isThrownBy(() -> this.tested.authenticate(this.token).block()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthenticationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isInstanceOf(AuthenticationException.class); + assertThat(captor.getValue()).isInstanceOf(AuthenticationObservationContext.class); + AuthenticationObservationContext context = (AuthenticationObservationContext) captor.getValue(); + assertThat(context.getAuthenticationManagerClass()).isEqualTo(this.authenticationManager.getClass()); + assertThat(context.getAuthenticationRequest()).isEqualTo(this.token); + assertThat(context.getAuthenticationResult()).isNull(); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/ObservationAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/ObservationAuthorizationManagerTests.java new file mode 100644 index 00000000000..16204bf50e8 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/ObservationAuthorizationManagerTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import java.util.function.Supplier; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ObservationAuthorizationManager} + */ +public class ObservationAuthorizationManagerTests { + + private ObservationRegistry registry; + + private ObservationHandler handler; + + private AuthorizationManager authorizationManager; + + private ObservationAuthorizationManager tested; + + private final Supplier token = () -> new TestingAuthenticationToken("user", "pass"); + + private final Object object = new Object(); + + private final AuthorizationDecision grant = new AuthorizationDecision(true); + + private final AuthorizationDecision deny = new AuthorizationDecision(false); + + @BeforeEach + void setup() { + this.handler = mock(ObservationHandler.class); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + this.registry = registry; + this.authorizationManager = mock(AuthorizationManager.class); + this.tested = new ObservationAuthorizationManager<>(this.registry, this.authorizationManager); + } + + @Test + void verifyWhenDefaultsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willReturn(this.grant); + this.tested.verify(this.token, this.object); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + assertThat(captor.getValue()).isInstanceOf(AuthorizationObservationContext.class); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isNull(); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.grant); + } + + @Test + void verifyWhenErrorsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willReturn(this.deny); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> this.tested.verify(this.token, this.object)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isInstanceOf(AccessDeniedException.class); + assertThat(captor.getValue()).isInstanceOf(AuthorizationObservationContext.class); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isNull(); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.deny); + } + + @Test + void verifyWhenLooksUpAuthenticationThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willAnswer((invocation) -> { + ((Supplier) invocation.getArgument(0)).get(); + return this.grant; + }); + this.tested.verify(this.token, this.object); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isEqualTo(this.token.get()); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.grant); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManagerTests.java new file mode 100644 index 00000000000..044c1c24b16 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/ObservationReactiveAuthorizationManagerTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.authorization; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ObservationAuthorizationManager} + */ +public class ObservationReactiveAuthorizationManagerTests { + + private ObservationRegistry registry; + + private ObservationHandler handler; + + private ReactiveAuthorizationManager authorizationManager; + + private ObservationReactiveAuthorizationManager tested; + + private final Mono token = Mono.just(new TestingAuthenticationToken("user", "pass")); + + private final Object object = new Object(); + + private final AuthorizationDecision grant = new AuthorizationDecision(true); + + private final AuthorizationDecision deny = new AuthorizationDecision(false); + + @BeforeEach + void setup() { + this.handler = mock(ObservationHandler.class); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + this.registry = registry; + this.authorizationManager = mock(ReactiveAuthorizationManager.class); + this.tested = new ObservationReactiveAuthorizationManager<>(this.registry, this.authorizationManager); + } + + @Test + void verifyWhenDefaultsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willReturn(Mono.just(this.grant)); + this.tested.verify(this.token, this.object).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + assertThat(captor.getValue()).isInstanceOf(AuthorizationObservationContext.class); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isNull(); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.grant); + } + + @Test + void verifyWhenErrorsThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willReturn(Mono.just(this.deny)); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> this.tested.verify(this.token, this.object).block()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isInstanceOf(AccessDeniedException.class); + assertThat(captor.getValue()).isInstanceOf(AuthorizationObservationContext.class); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isNull(); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.deny); + } + + @Test + void verifyWhenLooksUpAuthenticationThenObserves() { + given(this.handler.supportsContext(any())).willReturn(true); + given(this.authorizationManager.check(any(), any())).willAnswer((invocation) -> { + ((Mono) invocation.getArgument(0)).block(); + return Mono.just(this.grant); + }); + this.tested.verify(this.token, this.object).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(this.handler).onStart(captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo(AuthorizationObservationConvention.OBSERVATION_NAME); + assertThat(captor.getValue().getError()).isNull(); + AuthorizationObservationContext context = (AuthorizationObservationContext) captor.getValue(); + assertThat(context.getAuthentication()).isEqualTo(this.token.block()); + assertThat(context.getObject()).isEqualTo(this.object); + assertThat(context.getDecision()).isEqualTo(this.grant); + } + +} From 99a87179ddb6c715b24133903df1669290411246 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Wed, 21 Sep 2022 18:04:31 -0600 Subject: [PATCH 3/7] Instrument Filter Chain Closes gh-11911 --- .../annotation/web/builders/WebSecurity.java | 17 + .../WebFluxSecurityConfiguration.java | 16 +- .../HttpSecurityBeanDefinitionParser.java | 29 + .../HttpSecurityObservationTests.java | 115 ++++ .../security/config/http/HttpConfigTests.java | 51 ++ ...ttpConfigTests-WithObservationRegistry.xml | 40 ++ .../security/web/FilterChainProxy.java | 103 +++- .../web/ObservationFilterChainDecorator.java | 525 +++++++++++++++++ .../ObservationWebFilterChainDecorator.java | 531 ++++++++++++++++++ .../web/server/WebFilterChainProxy.java | 84 ++- .../security/web/FilterChainProxyTests.java | 200 ++++++- .../web/server/WebFilterChainProxyTests.java | 108 ++++ 12 files changed, 1798 insertions(+), 21 deletions(-) create mode 100644 config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecurityObservationTests.java create mode 100644 config/src/test/resources/org/springframework/security/config/http/HttpConfigTests-WithObservationRegistry.xml create mode 100644 web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java create mode 100644 web/src/main/java/org/springframework/security/web/server/ObservationWebFilterChainDecorator.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java index 6191798b3d4..c86f054c7ee 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.Filter; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; @@ -45,6 +46,7 @@ import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.FilterInvocation; +import org.springframework.security.web.ObservationFilterChainDecorator; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AuthorizationManagerWebInvocationPrivilegeEvaluator; import org.springframework.security.web.access.DefaultWebInvocationPrivilegeEvaluator; @@ -101,6 +103,8 @@ public final class WebSecurity extends AbstractConfiguredSecurityBuilder expressionHandler = this.defaultWebSecurityExpressionHandler; @@ -303,6 +307,7 @@ protected Filter performBuild() throws Exception { if (this.requestRejectedHandler != null) { filterChainProxy.setRequestRejectedHandler(this.requestRejectedHandler); } + filterChainProxy.setFilterChainDecorator(getFilterChainDecorator()); filterChainProxy.afterPropertiesSet(); Filter result = filterChainProxy; @@ -366,6 +371,11 @@ public void setApplicationContext(ApplicationContext applicationContext) throws } catch (NoSuchBeanDefinitionException ex) { } + try { + this.observationRegistry = applicationContext.getBean(ObservationRegistry.class); + } + catch (NoSuchBeanDefinitionException ex) { + } } @Override @@ -373,6 +383,13 @@ public void setServletContext(ServletContext servletContext) { this.servletContext = servletContext; } + FilterChainProxy.FilterChainDecorator getFilterChainDecorator() { + if (this.observationRegistry.isNoop()) { + return new FilterChainProxy.VirtualFilterChainDecorator(); + } + return new ObservationFilterChainDecorator(this.observationRegistry); + } + /** * Allows registering {@link RequestMatcher} instances that should be ignored by * Spring Security. diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/WebFluxSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/WebFluxSecurityConfiguration.java index 07119e8ee09..7bc58cbc4a8 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/WebFluxSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/WebFluxSecurityConfiguration.java @@ -19,6 +19,8 @@ import java.util.Arrays; import java.util.List; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.context.ApplicationContext; @@ -28,6 +30,7 @@ import org.springframework.security.config.crypto.RsaKeyConversionServicePostProcessor; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.reactive.result.view.CsrfRequestDataValueProcessor; +import org.springframework.security.web.server.ObservationWebFilterChainDecorator; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.util.ClassUtils; @@ -55,6 +58,8 @@ class WebFluxSecurityConfiguration { private List securityWebFilterChains; + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + @Autowired ApplicationContext context; @@ -63,10 +68,19 @@ void setSecurityWebFilterChains(List securityWebFilterCh this.securityWebFilterChains = securityWebFilterChains; } + @Autowired(required = false) + void setObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + @Bean(SPRING_SECURITY_WEBFILTERCHAINFILTER_BEAN_NAME) @Order(WEB_FILTER_CHAIN_FILTER_ORDER) WebFilterChainProxy springSecurityWebFilterChainFilter() { - return new WebFilterChainProxy(getSecurityWebFilterChains()); + WebFilterChainProxy proxy = new WebFilterChainProxy(getSecurityWebFilterChains()); + if (!this.observationRegistry.isNoop()) { + proxy.setFilterChainDecorator(new ObservationWebFilterChainDecorator(this.observationRegistry)); + } + return proxy; } @Bean(name = AbstractView.REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME) diff --git a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java index 44c6007c192..4ff9599d88c 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java @@ -56,6 +56,7 @@ import org.springframework.security.config.authentication.AuthenticationManagerFactoryBean; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.ObservationFilterChainDecorator; import org.springframework.security.web.PortResolverImpl; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.util.StringUtils; @@ -363,6 +364,10 @@ static void registerFilterChainProxyIfNecessary(ParserContext pc, Object source) fcpBldr.getRawBeanDefinition().setSource(source); fcpBldr.addConstructorArgReference(BeanIds.FILTER_CHAINS); fcpBldr.addPropertyValue("filterChainValidator", new RootBeanDefinition(DefaultFilterChainValidator.class)); + BeanDefinition filterChainDecorator = BeanDefinitionBuilder + .rootBeanDefinition(FilterChainDecoratorFactory.class) + .addPropertyValue("observationRegistry", getObservationRegistry(element)).getBeanDefinition(); + fcpBldr.addPropertyValue("filterChainDecorator", filterChainDecorator); BeanDefinition fcpBean = fcpBldr.getBeanDefinition(); pc.registerBeanComponent(new BeanComponentDefinition(fcpBean, BeanIds.FILTER_CHAIN_PROXY)); registry.registerAlias(BeanIds.FILTER_CHAIN_PROXY, BeanIds.SPRING_SECURITY_FILTER_CHAIN); @@ -509,4 +514,28 @@ public Class getObjectType() { } + public static final class FilterChainDecoratorFactory + implements FactoryBean { + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + @Override + public FilterChainProxy.FilterChainDecorator getObject() throws Exception { + if (this.observationRegistry.isNoop()) { + return new FilterChainProxy.VirtualFilterChainDecorator(); + } + return new ObservationFilterChainDecorator(this.observationRegistry); + } + + @Override + public Class getObjectType() { + return FilterChainProxy.FilterChainDecorator.class; + } + + public void setObservationRegistry(ObservationRegistry registry) { + this.observationRegistry = registry; + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecurityObservationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecurityObservationTests.java new file mode 100644 index 00000000000..7cc85c79678 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpSecurityObservationTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.config.annotation.web.configurers; + +import java.util.Iterator; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Josh Cummings + * + */ +@ExtendWith(SpringTestContextExtension.class) +public class HttpSecurityObservationTests { + + @Autowired + MockMvc mvc; + + public final SpringTestContext spring = new SpringTestContext(this); + + @Test + public void getWhenUsingObservationRegistryThenObservesRequest() throws Exception { + this.spring.register(ObservationRegistryConfig.class).autowire(); + // @formatter:off + this.mvc.perform(get("/").with(httpBasic("user", "password"))) + .andExpect(status().isNotFound()); + // @formatter:on + ObservationHandler handler = this.spring.getContext().getBean(ObservationHandler.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(5)).onStart(captor.capture()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getContextualName()).isEqualTo("spring.security.http.chains.before"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.authentications"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.authorizations"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.http.secured.requests"); + assertThat(contexts.next().getContextualName()).isEqualTo("spring.security.http.chains.after"); + } + + @EnableWebSecurity + @Configuration + static class ObservationRegistryConfig { + + private ObservationHandler handler = mock(ObservationHandler.class); + + @Bean + SecurityFilterChain app(HttpSecurity http) throws Exception { + http.httpBasic(withDefaults()).authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()); + return http.build(); + } + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder().username("user").password("password").authorities("app").build()); + } + + @Bean + ObservationHandler observationHandler() { + return this.handler; + } + + @Bean + ObservationRegistry observationRegistry() { + given(this.handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + return registry; + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java index 8be9526bc4b..9056fb6a77f 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpConfigTests.java @@ -16,13 +16,20 @@ package org.springframework.security.config.http; +import java.util.Iterator; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; import org.apache.http.HttpStatus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -36,7 +43,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -101,6 +111,24 @@ public void getWhenUsingMinimalConfigurationThenPreventsSessionAsUrlParameter() assertThat(response.getRedirectedUrl()).isEqualTo("http://localhost/login"); } + @Test + public void getWhenUsingObservationRegistryThenObservesRequest() throws Exception { + this.spring.configLocations(this.xml("WithObservationRegistry")).autowire(); + // @formatter:off + this.mvc.perform(get("/").with(httpBasic("user", "password"))) + .andExpect(status().isNotFound()); + // @formatter:on + ObservationHandler handler = this.spring.getContext().getBean(ObservationHandler.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(5)).onStart(captor.capture()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getContextualName()).isEqualTo("spring.security.http.chains.before"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.authentications"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.authorizations"); + assertThat(contexts.next().getName()).isEqualTo("spring.security.http.secured.requests"); + assertThat(contexts.next().getContextualName()).isEqualTo("spring.security.http.chains.after"); + } + private String xml(String configName) { return CONFIG_LOCATION_PREFIX + "-" + configName + ".xml"; } @@ -133,4 +161,27 @@ public String encodeRedirectUrl(String url) { } + public static final class MockObservationRegistry implements FactoryBean { + + private ObservationHandler handler = mock(ObservationHandler.class); + + @Override + public ObservationRegistry getObject() { + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(this.handler); + given(this.handler.supportsContext(any())).willReturn(true); + return registry; + } + + @Override + public Class getObjectType() { + return ObservationRegistry.class; + } + + public void setHandler(ObservationHandler handler) { + this.handler = handler; + } + + } + } diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpConfigTests-WithObservationRegistry.xml b/config/src/test/resources/org/springframework/security/config/http/HttpConfigTests-WithObservationRegistry.xml new file mode 100644 index 00000000000..d1d1839f9b6 --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpConfigTests-WithObservationRegistry.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java index 594812d860c..19415a24d33 100644 --- a/web/src/main/java/org/springframework/security/web/FilterChainProxy.java +++ b/web/src/main/java/org/springframework/security/web/FilterChainProxy.java @@ -160,6 +160,8 @@ public class FilterChainProxy extends GenericFilterBean { private ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); + private FilterChainDecorator filterChainDecorator = new VirtualFilterChainDecorator(); + public FilterChainProxy() { } @@ -214,14 +216,21 @@ private void doFilterInternal(ServletRequest request, ServletResponse response, logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest))); } firewallRequest.reset(); - chain.doFilter(firewallRequest, firewallResponse); + this.filterChainDecorator.decorate(chain).doFilter(firewallRequest, firewallResponse); return; } if (logger.isDebugEnabled()) { logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest))); } - VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters); - virtualFilterChain.doFilter(firewallRequest, firewallResponse); + FilterChain reset = (req, res) -> { + if (logger.isDebugEnabled()) { + logger.debug(LogMessage.of(() -> "Secured " + requestLine(firewallRequest))); + } + // Deactivate path stripping as we exit the security filter chain + firewallRequest.reset(); + chain.doFilter(req, res); + }; + this.filterChainDecorator.decorate(reset, filters).doFilter(firewallRequest, firewallResponse); } /** @@ -249,7 +258,7 @@ private List getFilters(HttpServletRequest request) { * @return matching filter list */ public List getFilters(String url) { - return getFilters(this.firewall.getFirewalledRequest((new FilterInvocation(url, "GET").getRequest()))); + return getFilters(this.firewall.getFirewalledRequest(new FilterInvocation(url, "GET").getRequest())); } /** @@ -281,6 +290,20 @@ public void setFilterChainValidator(FilterChainValidator filterChainValidator) { this.filterChainValidator = filterChainValidator; } + /** + * Used to decorate the original {@link FilterChain} for each request + * + *

+ * By default, this decorates the filter chain with a {@link VirtualFilterChain} that + * iterates through security filters and then delegates to the original chain + * @param filterChainDecorator the strategy for constructing the filter chain + * @since 6.0 + */ + public void setFilterChainDecorator(FilterChainDecorator filterChainDecorator) { + Assert.notNull(filterChainDecorator, "filterChainDecorator cannot be null"); + this.filterChainDecorator = filterChainDecorator; + } + /** * Sets the "firewall" implementation which will be used to validate and wrap (or * potentially reject) the incoming requests. The default implementation should be @@ -326,36 +349,27 @@ private static final class VirtualFilterChain implements FilterChain { private final List additionalFilters; - private final FirewalledRequest firewalledRequest; - private final int size; private int currentPosition = 0; - private VirtualFilterChain(FirewalledRequest firewalledRequest, FilterChain chain, - List additionalFilters) { + private VirtualFilterChain(FilterChain chain, List additionalFilters) { this.originalChain = chain; this.additionalFilters = additionalFilters; this.size = additionalFilters.size(); - this.firewalledRequest = firewalledRequest; } @Override public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { if (this.currentPosition == this.size) { - if (logger.isDebugEnabled()) { - logger.debug(LogMessage.of(() -> "Secured " + requestLine(this.firewalledRequest))); - } - // Deactivate path stripping as we exit the security filter chain - this.firewalledRequest.reset(); this.originalChain.doFilter(request, response); return; } this.currentPosition++; Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1); if (logger.isTraceEnabled()) { - logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), - this.currentPosition, this.size)); + String name = nextFilter.getClass().getSimpleName(); + logger.trace(LogMessage.format("Invoking %s (%d/%d)", name, this.currentPosition, this.size)); } nextFilter.doFilter(request, response, this); } @@ -376,4 +390,61 @@ public void validate(FilterChainProxy filterChainProxy) { } + /** + * A strategy for decorating the provided filter chain with one that accounts for the + * {@link SecurityFilterChain} for a given request. + * + * @author Josh Cummings + * @since 6.0 + */ + public interface FilterChainDecorator { + + /** + * Provide a new {@link FilterChain} that accounts for needed security + * considerations when there are no security filters. + * @param original the original {@link FilterChain} + * @return a security-enabled {@link FilterChain} + */ + default FilterChain decorate(FilterChain original) { + return decorate(original, Collections.emptyList()); + } + + /** + * Provide a new {@link FilterChain} that accounts for the provided filters as + * well as teh original filter chain. + * @param original the original {@link FilterChain} + * @param filters the security filters + * @return a security-enabled {@link FilterChain} that includes the provided + * filters + */ + FilterChain decorate(FilterChain original, List filters); + + } + + /** + * A {@link FilterChainDecorator} that uses the {@link VirtualFilterChain} + * + * @author Josh Cummings + * @since 6.0 + */ + public static final class VirtualFilterChainDecorator implements FilterChainDecorator { + + /** + * {@inheritDoc} + */ + @Override + public FilterChain decorate(FilterChain original) { + return original; + } + + /** + * {@inheritDoc} + */ + @Override + public FilterChain decorate(FilterChain original, List filters) { + return new VirtualFilterChain(original, filters); + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java b/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java new file mode 100644 index 00000000000..8f50c52a111 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java @@ -0,0 +1,525 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.web.util.UrlUtils; + +/** + * A {@link org.springframework.security.web.server.FilterChainProxy.FilterChainDecorator} + * that wraps the chain in before and after observations + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationFilterChainDecorator implements FilterChainProxy.FilterChainDecorator { + + private static final Log logger = LogFactory.getLog(FilterChainProxy.class); + + private static final String ATTRIBUTE = ObservationFilterChainDecorator.class + ".observation"; + + static final String UNSECURED_OBSERVATION_NAME = "spring.security.http.unsecured.requests"; + + static final String SECURED_OBSERVATION_NAME = "spring.security.http.secured.requests"; + + private final ObservationRegistry registry; + + public ObservationFilterChainDecorator(ObservationRegistry registry) { + this.registry = registry; + } + + @Override + public FilterChain decorate(FilterChain original) { + return wrapUnsecured(original); + } + + @Override + public FilterChain decorate(FilterChain original, List filters) { + return new VirtualFilterChain(wrapSecured(original), wrap(filters)); + } + + private FilterChain wrapSecured(FilterChain original) { + return (req, res) -> { + AroundFilterObservation parent = observation((HttpServletRequest) req); + Observation observation = Observation.createNotStarted(SECURED_OBSERVATION_NAME, this.registry); + parent.wrap(FilterObservation.create(observation).wrap(original)).doFilter(req, res); + }; + } + + private FilterChain wrapUnsecured(FilterChain original) { + return (req, res) -> { + Observation observation = Observation.createNotStarted(UNSECURED_OBSERVATION_NAME, this.registry); + FilterObservation.create(observation).wrap(original).doFilter(req, res); + }; + } + + private List wrap(List filters) { + int size = filters.size(); + List observableFilters = new ArrayList<>(); + int position = 1; + for (Filter filter : filters) { + observableFilters.add(new ObservationFilter(this.registry, filter, position, size)); + position++; + } + return observableFilters; + } + + static AroundFilterObservation observation(HttpServletRequest request) { + return (AroundFilterObservation) request.getAttribute(ATTRIBUTE); + } + + private static final class VirtualFilterChain implements FilterChain { + + private final FilterChain originalChain; + + private final List additionalFilters; + + private final int size; + + private int currentPosition = 0; + + private VirtualFilterChain(FilterChain chain, List additionalFilters) { + this.originalChain = chain; + this.additionalFilters = additionalFilters; + this.size = additionalFilters.size(); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + if (this.currentPosition == this.size) { + this.originalChain.doFilter(request, response); + return; + } + this.currentPosition++; + ObservationFilter nextFilter = this.additionalFilters.get(this.currentPosition - 1); + if (logger.isTraceEnabled()) { + String name = nextFilter.getName(); + logger.trace(LogMessage.format("Invoking %s (%d/%d)", name, this.currentPosition, this.size)); + } + nextFilter.doFilter(request, response, this); + } + + } + + static final class ObservationFilter implements Filter { + + private final ObservationRegistry registry; + + private final FilterChainObservationConvention convention = new FilterChainObservationConvention(); + + private final Filter filter; + + private final String name; + + private final int position; + + private final int size; + + ObservationFilter(ObservationRegistry registry, Filter filter, int position, int size) { + this.registry = registry; + this.filter = filter; + this.name = filter.getClass().getSimpleName(); + this.position = position; + this.size = size; + } + + String getName() { + return this.name; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (this.position == 1) { + AroundFilterObservation parent = parent((HttpServletRequest) request); + parent.wrap(this::wrapFilter).doFilter(request, response, chain); + } + else { + wrapFilter(request, response, chain); + } + } + + private void wrapFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + AroundFilterObservation parent = observation((HttpServletRequest) request); + FilterChainObservationContext parentBefore = (FilterChainObservationContext) parent.before().getContext(); + parentBefore.setChainSize(this.size); + parentBefore.setFilterName(this.name); + parentBefore.setChainPosition(this.position); + this.filter.doFilter(request, response, chain); + parent.start(); + FilterChainObservationContext parentAfter = (FilterChainObservationContext) parent.after().getContext(); + parentAfter.setChainSize(this.size); + parentAfter.setFilterName(this.name); + parentAfter.setChainPosition(this.size - this.position + 1); + } + + private AroundFilterObservation parent(HttpServletRequest request) { + FilterChainObservationContext beforeContext = FilterChainObservationContext.before(request); + FilterChainObservationContext afterContext = FilterChainObservationContext.after(request); + Observation before = Observation.createNotStarted(this.convention, () -> beforeContext, this.registry); + Observation after = Observation.createNotStarted(this.convention, () -> afterContext, this.registry); + AroundFilterObservation parent = AroundFilterObservation.create(before, after); + request.setAttribute(ATTRIBUTE, parent); + return parent; + } + + } + + interface AroundFilterObservation extends FilterObservation { + + AroundFilterObservation NOOP = new AroundFilterObservation() { + }; + + static AroundFilterObservation create(Observation before, Observation after) { + if (before.isNoop() || after.isNoop()) { + return NOOP; + } + return new SimpleAroundFilterObservation(before, after); + } + + default Observation before() { + return Observation.NOOP; + } + + default Observation after() { + return Observation.NOOP; + } + + class SimpleAroundFilterObservation implements AroundFilterObservation { + + private final Iterator observations; + + private final Observation before; + + private final Observation after; + + private final AtomicReference currentScope = new AtomicReference<>(null); + + SimpleAroundFilterObservation(Observation before, Observation after) { + this.before = before; + this.after = after; + this.observations = Arrays.asList(before, after).iterator(); + } + + @Override + public void start() { + if (this.observations.hasNext()) { + stop(); + Observation observation = this.observations.next(); + observation.start(); + Observation.Scope scope = observation.openScope(); + this.currentScope.set(scope); + } + } + + @Override + public void error(Throwable ex) { + Observation.Scope scope = this.currentScope.get(); + if (scope == null) { + return; + } + scope.close(); + scope.getCurrentObservation().error(ex); + } + + @Override + public void stop() { + Observation.Scope scope = this.currentScope.getAndSet(null); + if (scope == null) { + return; + } + scope.close(); + scope.getCurrentObservation().stop(); + } + + @Override + public Filter wrap(Filter filter) { + return (request, response, chain) -> { + start(); + try { + filter.doFilter(request, response, chain); + } + catch (Throwable ex) { + error(ex); + throw ex; + } + finally { + stop(); + } + }; + } + + @Override + public FilterChain wrap(FilterChain chain) { + return (request, response) -> { + stop(); + try { + chain.doFilter(request, response); + } + finally { + start(); + } + }; + } + + @Override + public Observation before() { + return this.before; + } + + @Override + public Observation after() { + return this.after; + } + + } + + } + + interface FilterObservation { + + FilterObservation NOOP = new FilterObservation() { + }; + + static FilterObservation create(Observation observation) { + if (observation.isNoop()) { + return NOOP; + } + return new SimpleFilterObservation(observation); + } + + default void start() { + } + + default void error(Throwable ex) { + } + + default void stop() { + } + + default Filter wrap(Filter filter) { + return filter; + } + + default FilterChain wrap(FilterChain chain) { + return chain; + } + + class SimpleFilterObservation implements FilterObservation { + + private final Observation observation; + + SimpleFilterObservation(Observation observation) { + this.observation = observation; + } + + @Override + public void start() { + this.observation.start(); + } + + @Override + public void error(Throwable ex) { + this.observation.error(ex); + } + + @Override + public void stop() { + this.observation.stop(); + } + + @Override + public Filter wrap(Filter filter) { + if (this.observation.isNoop()) { + return filter; + } + return (request, response, chain) -> { + this.observation.start(); + try (Observation.Scope scope = this.observation.openScope()) { + filter.doFilter(request, response, chain); + } + catch (Throwable ex) { + this.observation.error(ex); + throw ex; + } + finally { + this.observation.stop(); + } + }; + } + + @Override + public FilterChain wrap(FilterChain chain) { + if (this.observation.isNoop()) { + return chain; + } + return (request, response) -> { + this.observation.start(); + try (Observation.Scope scope = this.observation.openScope()) { + chain.doFilter(request, response); + } + catch (Throwable ex) { + this.observation.error(ex); + throw ex; + } + finally { + this.observation.stop(); + } + }; + } + + } + + } + + static final class FilterChainObservationContext extends Observation.Context { + + private final ServletRequest request; + + private final String filterSection; + + private String filterName; + + private int chainPosition; + + private int chainSize; + + private FilterChainObservationContext(ServletRequest request, String filterSection) { + this.filterSection = filterSection; + this.request = request; + } + + static FilterChainObservationContext before(ServletRequest request) { + return new FilterChainObservationContext(request, "before"); + } + + static FilterChainObservationContext after(ServletRequest request) { + return new FilterChainObservationContext(request, "after"); + } + + @Override + public void setName(String name) { + super.setName(name); + if (name != null) { + setContextualName(name + "." + this.filterSection); + } + } + + String getRequestLine() { + return requestLine((HttpServletRequest) this.request); + } + + String getFilterSection() { + return this.filterSection; + } + + String getFilterName() { + return this.filterName; + } + + void setFilterName(String filterName) { + this.filterName = filterName; + } + + int getChainPosition() { + return this.chainPosition; + } + + void setChainPosition(int chainPosition) { + this.chainPosition = chainPosition; + } + + int getChainSize() { + return this.chainSize; + } + + void setChainSize(int chainSize) { + this.chainSize = chainSize; + } + + private static String requestLine(HttpServletRequest request) { + return request.getMethod() + " " + UrlUtils.buildRequestUrl(request); + } + + } + + static final class FilterChainObservationConvention + implements ObservationConvention { + + static final String CHAIN_OBSERVATION_NAME = "spring.security.http.chains"; + + private static final String REQUEST_LINE_NAME = "request.line"; + + private static final String CHAIN_POSITION_NAME = "chain.position"; + + private static final String CHAIN_SIZE_NAME = "chain.size"; + + private static final String FILTER_SECTION_NAME = "filter.section"; + + private static final String FILTER_NAME = "current.filter.name"; + + @Override + public String getName() { + return CHAIN_OBSERVATION_NAME; + } + + @Override + public KeyValues getLowCardinalityKeyValues(FilterChainObservationContext context) { + KeyValues kv = KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize())) + .and(CHAIN_POSITION_NAME, String.valueOf(context.getChainPosition())) + .and(FILTER_SECTION_NAME, context.getFilterSection()); + if (context.getFilterName() != null) { + kv = kv.and(FILTER_NAME, context.getFilterName()); + } + return kv; + } + + @Override + public KeyValues getHighCardinalityKeyValues(FilterChainObservationContext context) { + String requestLine = context.getRequestLine(); + return KeyValues.of(REQUEST_LINE_NAME, requestLine); + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof FilterChainObservationContext; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/ObservationWebFilterChainDecorator.java b/web/src/main/java/org/springframework/security/web/server/ObservationWebFilterChainDecorator.java new file mode 100644 index 00000000000..c815dae5a00 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/ObservationWebFilterChainDecorator.java @@ -0,0 +1,531 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.web.server; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.atomic.AtomicReference; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.ObservationRegistry; +import reactor.core.publisher.Mono; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebHandler; + +/** + * A + * {@link org.springframework.security.web.server.WebFilterChainProxy.WebFilterChainDecorator} + * that wraps the chain in before and after observations + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationWebFilterChainDecorator implements WebFilterChainProxy.WebFilterChainDecorator { + + private static final String ATTRIBUTE = ObservationWebFilterChainDecorator.class + ".observation"; + + static final String UNSECURED_OBSERVATION_NAME = "spring.security.http.unsecured.requests"; + + static final String SECURED_OBSERVATION_NAME = "spring.security.http.secured.requests"; + + private final ObservationRegistry registry; + + public ObservationWebFilterChainDecorator(ObservationRegistry registry) { + this.registry = registry; + } + + @Override + public WebFilterChain decorate(WebFilterChain original) { + return wrapUnsecured(original); + } + + @Override + public WebFilterChain decorate(WebFilterChain original, List filters) { + return new ObservationWebFilterChain(wrapSecured(original)::filter, wrap(filters)); + } + + private static AroundWebFilterObservation observation(ServerWebExchange exchange) { + return exchange.getAttribute(ATTRIBUTE); + } + + private WebFilterChain wrapSecured(WebFilterChain original) { + return (exchange) -> { + AroundWebFilterObservation parent = observation(exchange); + Observation observation = Observation.createNotStarted(SECURED_OBSERVATION_NAME, this.registry); + return parent.wrap(WebFilterObservation.create(observation).wrap(original)).filter(exchange); + }; + } + + private WebFilterChain wrapUnsecured(WebFilterChain original) { + return (exchange) -> { + Observation observation = Observation.createNotStarted(UNSECURED_OBSERVATION_NAME, this.registry); + return WebFilterObservation.create(observation).wrap(original).filter(exchange); + }; + } + + private List wrap(List filters) { + int size = filters.size(); + List observableFilters = new ArrayList<>(); + int position = 1; + for (WebFilter filter : filters) { + observableFilters.add(new ObservationWebFilter(this.registry, filter, position, size)); + position++; + } + return observableFilters; + } + + static class ObservationWebFilterChain implements WebFilterChain { + + private final WebHandler handler; + + @Nullable + private final ObservationWebFilter currentFilter; + + @Nullable + private final ObservationWebFilterChain chain; + + /** + * Public constructor with the list of filters and the target handler to use. + * @param handler the target handler + * @param filters the filters ahead of the handler + * @since 5.1 + */ + ObservationWebFilterChain(WebHandler handler, List filters) { + Assert.notNull(handler, "WebHandler is required"); + this.handler = handler; + ObservationWebFilterChain chain = initChain(filters, handler); + this.currentFilter = chain.currentFilter; + this.chain = chain.chain; + } + + private static ObservationWebFilterChain initChain(List filters, WebHandler handler) { + ObservationWebFilterChain chain = new ObservationWebFilterChain(handler, null, null); + ListIterator iterator = filters.listIterator(filters.size()); + while (iterator.hasPrevious()) { + chain = new ObservationWebFilterChain(handler, iterator.previous(), chain); + } + return chain; + } + + /** + * Private constructor to represent one link in the chain. + */ + private ObservationWebFilterChain(WebHandler handler, @Nullable ObservationWebFilter currentFilter, + @Nullable ObservationWebFilterChain chain) { + this.currentFilter = currentFilter; + this.handler = handler; + this.chain = chain; + } + + @Override + public Mono filter(ServerWebExchange exchange) { + return Mono.defer(() -> (this.currentFilter != null && this.chain != null) + ? invokeFilter(this.currentFilter, this.chain, exchange) : this.handler.handle(exchange)); + } + + private Mono invokeFilter(ObservationWebFilter current, ObservationWebFilterChain chain, + ServerWebExchange exchange) { + String currentName = current.getName(); + return current.filter(exchange, chain).checkpoint(currentName + " [DefaultWebFilterChain]"); + } + + } + + static final class ObservationWebFilter implements WebFilter { + + private final ObservationRegistry registry; + + private final WebFilterChainObservationConvention convention = new WebFilterChainObservationConvention(); + + private final WebFilter filter; + + private final String name; + + private final int position; + + private final int size; + + ObservationWebFilter(ObservationRegistry registry, WebFilter filter, int position, int size) { + this.registry = registry; + this.filter = filter; + this.name = filter.getClass().getSimpleName(); + this.position = position; + this.size = size; + } + + String getName() { + return this.name; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + if (this.position == 1) { + AroundWebFilterObservation parent = parent(exchange); + return parent.wrap(this::wrapFilter).filter(exchange, chain); + } + else { + return wrapFilter(exchange, chain); + } + } + + private Mono wrapFilter(ServerWebExchange exchange, WebFilterChain chain) { + AroundWebFilterObservation parent = observation(exchange); + WebFilterChainObservationContext parentBefore = (WebFilterChainObservationContext) parent.before() + .getContext(); + parentBefore.setChainSize(this.size); + parentBefore.setFilterName(this.name); + parentBefore.setChainPosition(this.position); + return this.filter.filter(exchange, chain).doOnSuccess((result) -> { + parent.start(); + WebFilterChainObservationContext parentAfter = (WebFilterChainObservationContext) parent.after() + .getContext(); + parentAfter.setChainSize(this.size); + parentAfter.setFilterName(this.name); + parentAfter.setChainPosition(this.size - this.position + 1); + }); + } + + private AroundWebFilterObservation parent(ServerWebExchange exchange) { + WebFilterChainObservationContext beforeContext = WebFilterChainObservationContext.before(exchange); + WebFilterChainObservationContext afterContext = WebFilterChainObservationContext.after(exchange); + Observation before = Observation.createNotStarted(this.convention, () -> beforeContext, this.registry); + Observation after = Observation.createNotStarted(this.convention, () -> afterContext, this.registry); + AroundWebFilterObservation parent = AroundWebFilterObservation.create(before, after); + exchange.getAttributes().put(ATTRIBUTE, parent); + return parent; + } + + } + + interface AroundWebFilterObservation extends WebFilterObservation { + + AroundWebFilterObservation NOOP = new AroundWebFilterObservation() { + }; + + static AroundWebFilterObservation create(Observation before, Observation after) { + if (before.isNoop() || after.isNoop()) { + return NOOP; + } + return new SimpleAroundWebFilterObservation(before, after); + } + + default Observation before() { + return Observation.NOOP; + } + + default Observation after() { + return Observation.NOOP; + } + + class SimpleAroundWebFilterObservation implements AroundWebFilterObservation { + + private final Iterator observations; + + private final Observation before; + + private final Observation after; + + private final AtomicReference currentObservation = new AtomicReference<>(null); + + SimpleAroundWebFilterObservation(Observation before, Observation after) { + this.before = before; + this.after = after; + this.observations = Arrays.asList(before, after).iterator(); + } + + @Override + public void start() { + if (this.observations.hasNext()) { + stop(); + Observation observation = this.observations.next(); + observation.start(); + this.currentObservation.set(observation); + } + } + + @Override + public void error(Throwable ex) { + Observation observation = this.currentObservation.get(); + if (observation == null) { + return; + } + observation.error(ex); + } + + @Override + public void stop() { + Observation observation = this.currentObservation.getAndSet(null); + if (observation == null) { + return; + } + observation.stop(); + } + + @Override + public WebFilterChain wrap(WebFilterChain chain) { + return (exchange) -> { + stop(); + // @formatter:off + return chain.filter(exchange) + .doOnSuccess((v) -> start()) + .doOnCancel(this::start) + .doOnError((t) -> { + error(t); + start(); + }); + // @formatter:on + }; + } + + @Override + public WebFilter wrap(WebFilter filter) { + return (exchange, chain) -> { + start(); + // @formatter:off + return filter.filter(exchange, chain) + .doOnSuccess((v) -> stop()) + .doOnCancel(this::stop) + .doOnError((t) -> { + error(t); + stop(); + }); + // @formatter:on + }; + } + + @Override + public Observation before() { + return this.before; + } + + @Override + public Observation after() { + return this.after; + } + + } + + } + + interface WebFilterObservation { + + WebFilterObservation NOOP = new WebFilterObservation() { + }; + + static WebFilterObservation create(Observation observation) { + if (observation.isNoop()) { + return NOOP; + } + return new SimpleWebFilterObservation(observation); + } + + default void start() { + } + + default void error(Throwable ex) { + } + + default void stop() { + } + + default WebFilter wrap(WebFilter filter) { + return filter; + } + + default WebFilterChain wrap(WebFilterChain chain) { + return chain; + } + + class SimpleWebFilterObservation implements WebFilterObservation { + + private final Observation observation; + + SimpleWebFilterObservation(Observation observation) { + this.observation = observation; + } + + @Override + public void start() { + this.observation.start(); + } + + @Override + public void error(Throwable ex) { + this.observation.error(ex); + } + + @Override + public void stop() { + this.observation.stop(); + } + + @Override + public WebFilter wrap(WebFilter filter) { + if (this.observation.isNoop()) { + return filter; + } + return (exchange, chain) -> { + this.observation.start(); + return filter.filter(exchange, chain).doOnSuccess((v) -> this.observation.stop()) + .doOnCancel(this.observation::stop).doOnError((t) -> { + this.observation.error(t); + this.observation.stop(); + }); + }; + } + + @Override + public WebFilterChain wrap(WebFilterChain chain) { + if (this.observation.isNoop()) { + return chain; + } + return (exchange) -> { + this.observation.start(); + return chain.filter(exchange).doOnSuccess((v) -> this.observation.stop()) + .doOnCancel(this.observation::stop).doOnError((t) -> { + this.observation.error(t); + this.observation.stop(); + }); + }; + } + + } + + } + + static final class WebFilterChainObservationContext extends Observation.Context { + + private final ServerWebExchange exchange; + + private final String filterSection; + + private String filterName; + + private int chainPosition; + + private int chainSize; + + private WebFilterChainObservationContext(ServerWebExchange exchange, String filterSection) { + this.exchange = exchange; + this.filterSection = filterSection; + } + + static WebFilterChainObservationContext before(ServerWebExchange exchange) { + return new WebFilterChainObservationContext(exchange, "before"); + } + + static WebFilterChainObservationContext after(ServerWebExchange exchange) { + return new WebFilterChainObservationContext(exchange, "after"); + } + + @Override + public void setName(String name) { + super.setName(name); + if (name != null) { + setContextualName(name + "." + this.filterSection); + } + } + + String getRequestLine() { + return this.exchange.getRequest().getPath().toString(); + } + + String getFilterSection() { + return this.filterSection; + } + + String getFilterName() { + return this.filterName; + } + + void setFilterName(String filterName) { + this.filterName = filterName; + } + + int getChainPosition() { + return this.chainPosition; + } + + void setChainPosition(int chainPosition) { + this.chainPosition = chainPosition; + } + + int getChainSize() { + return this.chainSize; + } + + void setChainSize(int chainSize) { + this.chainSize = chainSize; + } + + } + + static final class WebFilterChainObservationConvention + implements ObservationConvention { + + static final String CHAIN_OBSERVATION_NAME = "spring.security.http.chains"; + + private static final String REQUEST_LINE_NAME = "request.line"; + + private static final String CHAIN_POSITION_NAME = "chain.position"; + + private static final String CHAIN_SIZE_NAME = "chain.size"; + + private static final String FILTER_SECTION_NAME = "filter.section"; + + private static final String FILTER_NAME = "current.filter.name"; + + @Override + public String getName() { + return CHAIN_OBSERVATION_NAME; + } + + @Override + public KeyValues getLowCardinalityKeyValues(WebFilterChainObservationContext context) { + KeyValues kv = KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize())) + .and(CHAIN_POSITION_NAME, String.valueOf(context.getChainPosition())) + .and(FILTER_SECTION_NAME, context.getFilterSection()); + if (context.getFilterName() != null) { + kv = kv.and(FILTER_NAME, context.getFilterName()); + } + return kv; + } + + @Override + public KeyValues getHighCardinalityKeyValues(WebFilterChainObservationContext context) { + String requestLine = context.getRequestLine(); + return KeyValues.of(REQUEST_LINE_NAME, requestLine); + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof WebFilterChainObservationContext; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java b/web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java index 33704a096fb..31a1156c5f9 100644 --- a/web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java +++ b/web/src/main/java/org/springframework/security/web/server/WebFilterChainProxy.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,11 +17,15 @@ package org.springframework.security.web.server; import java.util.Arrays; +import java.util.Collections; import java.util.List; +import jakarta.servlet.FilterChain; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; @@ -37,6 +41,8 @@ public class WebFilterChainProxy implements WebFilter { private final List filters; + private WebFilterChainDecorator filterChainDecorator = new DefaultWebFilterChainDecorator(); + public WebFilterChainProxy(List filters) { this.filters = filters; } @@ -49,10 +55,82 @@ public WebFilterChainProxy(SecurityWebFilterChain... filters) { public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return Flux.fromIterable(this.filters) .filterWhen((securityWebFilterChain) -> securityWebFilterChain.matches(exchange)).next() - .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .switchIfEmpty( + Mono.defer(() -> this.filterChainDecorator.decorate(chain).filter(exchange).then(Mono.empty()))) .flatMap((securityWebFilterChain) -> securityWebFilterChain.getWebFilters().collectList()) - .map((filters) -> new DefaultWebFilterChain(chain::filter, filters)) + .map((filters) -> this.filterChainDecorator.decorate(chain, filters)) .flatMap((securedChain) -> securedChain.filter(exchange)); } + /** + * Used to decorate the original {@link FilterChain} for each request + * + *

+ * By default, this decorates the filter chain with a {@link DefaultWebFilterChain} + * that iterates through security filters and then delegates to the original chain + * @param filterChainDecorator the strategy for constructing the filter chain + * @since 6.0 + */ + public void setFilterChainDecorator(WebFilterChainDecorator filterChainDecorator) { + Assert.notNull(filterChainDecorator, "filterChainDecorator cannot be null"); + this.filterChainDecorator = filterChainDecorator; + } + + /** + * A strategy for decorating the provided filter chain with one that accounts for the + * {@link SecurityFilterChain} for a given request. + * + * @author Josh Cummings + * @since 6.0 + */ + public interface WebFilterChainDecorator { + + /** + * Provide a new {@link FilterChain} that accounts for needed security + * considerations when there are no security filters. + * @param original the original {@link FilterChain} + * @return a security-enabled {@link FilterChain} + */ + default WebFilterChain decorate(WebFilterChain original) { + return decorate(original, Collections.emptyList()); + } + + /** + * Provide a new {@link FilterChain} that accounts for the provided filters as + * well as teh original filter chain. + * @param original the original {@link FilterChain} + * @param filters the security filters + * @return a security-enabled {@link FilterChain} that includes the provided + * filters + */ + WebFilterChain decorate(WebFilterChain original, List filters); + + } + + /** + * A {@link WebFilterChainDecorator} that uses the {@link DefaultWebFilterChain} + * + * @author Josh Cummings + * @since 6.0 + */ + public static class DefaultWebFilterChainDecorator implements WebFilterChainDecorator { + + /** + * {@inheritDoc} + */ + @Override + public WebFilterChain decorate(WebFilterChain original) { + return original; + } + + /** + * {@inheritDoc} + */ + @Override + public WebFilterChain decorate(WebFilterChain original, List filters) { + return new DefaultWebFilterChain(original::filter, filters); + } + + } + } diff --git a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java index 16f65811529..34fe85c6b66 100644 --- a/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java +++ b/web/src/test/java/org/springframework/security/web/FilterChainProxyTests.java @@ -16,19 +16,27 @@ package org.springframework.security.web; +import java.io.IOException; import java.util.Arrays; import java.util.Collections; +import java.util.Iterator; import java.util.List; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.stubbing.Answer; import org.springframework.mock.web.MockHttpServletRequest; @@ -50,7 +58,9 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willAnswer; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -270,10 +280,198 @@ public void requestRejectedHandlerIsCalledIfFirewallThrowsWrappedRequestRejected this.fcp.setRequestRejectedHandler(rjh); RequestRejectedException requestRejectedException = new RequestRejectedException("Contains illegal chars"); ServletException servletException = new ServletException(requestRejectedException); - given(fw.getFirewalledRequest(this.request)).willReturn(mock(FirewalledRequest.class)); + given(fw.getFirewalledRequest(this.request)).willReturn(new MockFirewalledRequest(this.request)); willThrow(servletException).given(this.chain).doFilter(any(), any()); this.fcp.doFilter(this.request, this.response, this.chain); verify(rjh).handle(eq(this.request), eq(this.response), eq((requestRejectedException))); } + @Test + public void doFilterWhenMatchesThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + given(this.matcher.matches(any())).willReturn(true); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, Arrays.asList(this.filter)); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + filter.doFilter(this.request, this.response, this.chain); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(4)).onStart(captor.capture()); + verify(handler, times(4)).onStop(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 1); + assertThat(contexts.next().getName()).isEqualTo(ObservationFilterChainDecorator.SECURED_OBSERVATION_NAME); + assertFilterChainObservation(contexts.next(), "after", 1); + } + + @Test + public void doFilterWhenMultipleFiltersThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + given(this.matcher.matches(any())).willReturn(true); + Filter one = mockFilter(); + Filter two = mockFilter(); + Filter three = mockFilter(); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, one, two, three); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + filter.doFilter(this.request, this.response, this.chain); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(4)).onStart(captor.capture()); + verify(handler, times(4)).onStop(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 3); + assertThat(contexts.next().getName()).isEqualTo(ObservationFilterChainDecorator.SECURED_OBSERVATION_NAME); + assertFilterChainObservation(contexts.next(), "after", 3); + } + + @Test + public void doFilterWhenMismatchesThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, Arrays.asList(this.filter)); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + filter.doFilter(this.request, this.response, this.chain); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(2)).onStart(captor.capture()); + verify(handler, times(2)).onStop(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertThat(contexts.next().getName()).isEqualTo(ObservationFilterChainDecorator.UNSECURED_OBSERVATION_NAME); + } + + @Test + public void doFilterWhenFilterExceptionThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + willThrow(IllegalStateException.class).given(this.filter).doFilter(any(), any(), any()); + given(this.matcher.matches(any())).willReturn(true); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, Arrays.asList(this.filter)); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> filter.doFilter(this.request, this.response, this.chain)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(2)).onStart(captor.capture()); + verify(handler, times(2)).onStop(any()); + verify(handler, atLeastOnce()).onError(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 1); + } + + @Test + public void doFilterWhenExceptionWithMultipleFiltersThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + given(this.matcher.matches(any())).willReturn(true); + Filter one = mockFilter(); + Filter two = mock(Filter.class); + willThrow(IllegalStateException.class).given(two).doFilter(any(), any(), any()); + Filter three = mockFilter(); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, one, two, three); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> filter.doFilter(this.request, this.response, this.chain)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(2)).onStart(captor.capture()); + verify(handler, times(2)).onStop(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 2); + } + + @Test + public void doFilterWhenOneFilterDoesNotProceedThenObservationRegistryObserves() throws Exception { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + given(this.matcher.matches(any())).willReturn(true); + Filter one = mockFilter(); + Filter two = mock(Filter.class); + Filter three = mockFilter(); + SecurityFilterChain sec = new DefaultSecurityFilterChain(this.matcher, one, two, three); + FilterChainProxy fcp = new FilterChainProxy(sec); + fcp.setFilterChainDecorator(new ObservationFilterChainDecorator(registry)); + Filter filter = ObservationFilterChainDecorator.FilterObservation + .create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + filter.doFilter(this.request, this.response, this.chain); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(3)).onStart(captor.capture()); + verify(handler, times(3)).onStop(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 2); + assertFilterChainObservation(contexts.next(), "after", 3); + } + + static void assertFilterChainObservation(Observation.Context context, String filterSection, int chainPosition) { + assertThat(context).isInstanceOf(ObservationFilterChainDecorator.FilterChainObservationContext.class); + ObservationFilterChainDecorator.FilterChainObservationContext filterChainObservationContext = (ObservationFilterChainDecorator.FilterChainObservationContext) context; + assertThat(context.getName()) + .isEqualTo(ObservationFilterChainDecorator.FilterChainObservationConvention.CHAIN_OBSERVATION_NAME); + assertThat(context.getContextualName()).endsWith(filterSection); + assertThat(filterChainObservationContext.getChainPosition()).isEqualTo(chainPosition); + } + + static Filter mockFilter() throws Exception { + Filter filter = mock(Filter.class); + willAnswer((invocation) -> { + HttpServletRequest request = invocation.getArgument(0, HttpServletRequest.class); + HttpServletResponse response = invocation.getArgument(1, HttpServletResponse.class); + FilterChain chain = invocation.getArgument(2, FilterChain.class); + chain.doFilter(request, response); + return null; + }).given(filter).doFilter(any(), any(), any()); + return filter; + } + + private static class MockFirewalledRequest extends FirewalledRequest { + + MockFirewalledRequest(HttpServletRequest request) { + super(request); + } + + @Override + public void reset() { + + } + + } + + private static class MockFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + chain.doFilter(request, response); + } + + } + } diff --git a/web/src/test/java/org/springframework/security/web/server/WebFilterChainProxyTests.java b/web/src/test/java/org/springframework/security/web/server/WebFilterChainProxyTests.java index 4f527b68504..6c795cb640b 100644 --- a/web/src/test/java/org/springframework/security/web/server/WebFilterChainProxyTests.java +++ b/web/src/test/java/org/springframework/security/web/server/WebFilterChainProxyTests.java @@ -17,12 +17,22 @@ package org.springframework.security.web.server; import java.util.Arrays; +import java.util.Iterator; import java.util.List; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterChainObservationContext; +import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterChainObservationConvention; +import org.springframework.security.web.server.ObservationWebFilterChainDecorator.WebFilterObservation; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.test.web.reactive.server.WebTestClient; @@ -30,6 +40,15 @@ import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + /** * @author Rob Winch * @since 5.0 @@ -47,6 +66,86 @@ public void filterWhenNoMatchThenContinuesChainAnd404() { .isNotFound(); } + @Test + public void doFilterWhenMatchesThenObservationRegistryObserves() { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + List filters = Arrays.asList(new PassthroughWebFilter()); + ServerWebExchangeMatcher match = (exchange) -> MatchResult.match(); + MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(match, filters); + WebFilterChainProxy fcp = new WebFilterChainProxy(chain); + fcp.setFilterChainDecorator(new ObservationWebFilterChainDecorator(registry)); + WebFilter filter = WebFilterObservation.create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + WebFilterChain mockChain = mock(WebFilterChain.class); + given(mockChain.filter(any())).willReturn(Mono.empty()); + filter.filter(MockServerWebExchange.from(MockServerHttpRequest.get("/")), mockChain).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(4)).onStart(captor.capture()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 1); + assertThat(contexts.next().getName()).isEqualTo(ObservationWebFilterChainDecorator.SECURED_OBSERVATION_NAME); + assertFilterChainObservation(contexts.next(), "after", 1); + } + + @Test + public void doFilterWhenMismatchesThenObservationRegistryObserves() { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + List filters = Arrays.asList(new PassthroughWebFilter()); + ServerWebExchangeMatcher notMatch = (exchange) -> MatchResult.notMatch(); + MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(notMatch, filters); + WebFilterChainProxy fcp = new WebFilterChainProxy(chain); + fcp.setFilterChainDecorator(new ObservationWebFilterChainDecorator(registry)); + WebFilter filter = WebFilterObservation.create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + WebFilterChain mockChain = mock(WebFilterChain.class); + given(mockChain.filter(any())).willReturn(Mono.empty()); + filter.filter(MockServerWebExchange.from(MockServerHttpRequest.get("/")), mockChain).block(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(2)).onStart(captor.capture()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertThat(contexts.next().getName()).isEqualTo(ObservationWebFilterChainDecorator.UNSECURED_OBSERVATION_NAME); + } + + @Test + public void doFilterWhenFilterExceptionThenObservationRegistryObserves() { + ObservationHandler handler = mock(ObservationHandler.class); + given(handler.supportsContext(any())).willReturn(true); + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(handler); + WebFilter error = mock(WebFilter.class); + given(error.filter(any(), any())).willReturn(Mono.error(new IllegalStateException())); + List filters = Arrays.asList(error); + ServerWebExchangeMatcher match = (exchange) -> MatchResult.match(); + MatcherSecurityWebFilterChain chain = new MatcherSecurityWebFilterChain(match, filters); + WebFilterChainProxy fcp = new WebFilterChainProxy(chain); + fcp.setFilterChainDecorator(new ObservationWebFilterChainDecorator(registry)); + WebFilter filter = WebFilterObservation.create(Observation.createNotStarted("wrap", registry)).wrap(fcp); + WebFilterChain mockChain = mock(WebFilterChain.class); + given(mockChain.filter(any())).willReturn(Mono.empty()); + assertThatExceptionOfType(IllegalStateException.class).isThrownBy( + () -> filter.filter(MockServerWebExchange.from(MockServerHttpRequest.get("/")), mockChain).block()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Observation.Context.class); + verify(handler, times(2)).onStart(captor.capture()); + verify(handler, atLeastOnce()).onError(any()); + Iterator contexts = captor.getAllValues().iterator(); + assertThat(contexts.next().getName()).isEqualTo("wrap"); + assertFilterChainObservation(contexts.next(), "before", 1); + } + + static void assertFilterChainObservation(Observation.Context context, String filterSection, int chainPosition) { + assertThat(context).isInstanceOf(WebFilterChainObservationContext.class); + WebFilterChainObservationContext filterChainObservationContext = (WebFilterChainObservationContext) context; + assertThat(context.getName()).isEqualTo(WebFilterChainObservationConvention.CHAIN_OBSERVATION_NAME); + assertThat(context.getContextualName()).endsWith(filterSection); + assertThat(filterChainObservationContext.getChainPosition()).isEqualTo(chainPosition); + } + static class Http200WebFilter implements WebFilter { @Override @@ -56,4 +155,13 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { } + static class PassthroughWebFilter implements WebFilter { + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(exchange); + } + + } + } From d3d8f7d60fea379130bfdec1184b6df2ba1cd7f9 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Fri, 30 Sep 2022 14:18:07 -0600 Subject: [PATCH 4/7] Mark Observations with Security Context Events Closes gh-11992 --- ...rvationSecurityContextChangedListener.java | 96 +++++++++++++++++ ...onSecurityContextChangedListenerTests.java | 101 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 core/src/main/java/org/springframework/security/core/context/ObservationSecurityContextChangedListener.java create mode 100644 core/src/test/java/org/springframework/security/core/context/ObservationSecurityContextChangedListenerTests.java diff --git a/core/src/main/java/org/springframework/security/core/context/ObservationSecurityContextChangedListener.java b/core/src/main/java/org/springframework/security/core/context/ObservationSecurityContextChangedListener.java new file mode 100644 index 00000000000..a8c141e7f84 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/context/ObservationSecurityContextChangedListener.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.core.context; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.security.core.Authentication; + +/** + * A {@link SecurityContextChangedListener} that adds events to an existing + * {@link Observation} + * + * If no {@link Observation} is present when an event is fired, then the event is + * unrecorded. + * + * @author Josh Cummings + * @since 6.0 + */ +public final class ObservationSecurityContextChangedListener implements SecurityContextChangedListener { + + private static final String SECURITY_CONTEXT_CREATED = "security.context.created"; + + private static final String SECURITY_CONTEXT_CHANGED = "security.context.changed"; + + private static final String SECURITY_CONTEXT_CLEARED = "security.context.cleared"; + + private final ObservationRegistry registry; + + /** + * Create a {@link ObservationSecurityContextChangedListener} + * @param registry the {@link ObservationRegistry} for looking up the surrounding + * {@link Observation} + */ + public ObservationSecurityContextChangedListener(ObservationRegistry registry) { + this.registry = registry; + } + + /** + * {@inheritDoc} + */ + @Override + public void securityContextChanged(SecurityContextChangedEvent event) { + Observation observation = this.registry.getCurrentObservation(); + if (observation == null) { + return; + } + if (event.isCleared()) { + observation.event(Observation.Event.of("security.context.cleared")); + return; + } + Authentication oldAuthentication = getAuthentication(event.getOldContext()); + Authentication newAuthentication = getAuthentication(event.getNewContext()); + if (oldAuthentication == null && newAuthentication == null) { + return; + } + if (oldAuthentication == null) { + observation.event(Observation.Event.of(SECURITY_CONTEXT_CREATED, "%s [%s]").format(SECURITY_CONTEXT_CREATED, + newAuthentication.getClass().getSimpleName())); + return; + } + if (newAuthentication == null) { + observation.event(Observation.Event.of(SECURITY_CONTEXT_CLEARED, "%s [%s]").format(SECURITY_CONTEXT_CLEARED, + oldAuthentication.getClass().getSimpleName())); + return; + } + if (oldAuthentication.equals(newAuthentication)) { + return; + } + observation.event( + Observation.Event.of(SECURITY_CONTEXT_CHANGED, "%s [%s] -> [%s]").format(SECURITY_CONTEXT_CHANGED, + oldAuthentication.getClass().getSimpleName(), newAuthentication.getClass().getSimpleName())); + } + + private static Authentication getAuthentication(SecurityContext context) { + if (context == null) { + return null; + } + return context.getAuthentication(); + } + +} diff --git a/core/src/test/java/org/springframework/security/core/context/ObservationSecurityContextChangedListenerTests.java b/core/src/test/java/org/springframework/security/core/context/ObservationSecurityContextChangedListenerTests.java new file mode 100644 index 00000000000..750a352b64d --- /dev/null +++ b/core/src/test/java/org/springframework/security/core/context/ObservationSecurityContextChangedListenerTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.core.context; + +import java.util.function.Supplier; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.TestingAuthenticationToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link ObservationSecurityContextChangedListener} + */ +public class ObservationSecurityContextChangedListenerTests { + + private SecurityContext one = new SecurityContextImpl(new TestingAuthenticationToken("user", "pass")); + + private SecurityContext two = new SecurityContextImpl(new TestingAuthenticationToken("admin", "pass")); + + private ObservationRegistry observationRegistry; + + private ObservationSecurityContextChangedListener tested; + + @BeforeEach + void setup() { + this.observationRegistry = mock(ObservationRegistry.class); + this.tested = new ObservationSecurityContextChangedListener(this.observationRegistry); + } + + @Test + void securityContextChangedWhenNoObservationThenNoEvents() { + given(this.observationRegistry.getCurrentObservation()).willReturn(null); + this.tested.securityContextChanged(new SecurityContextChangedEvent(this.one, this.two)); + } + + @Test + void securityContextChangedWhenClearedEventThenAddsClearEventToObservation() { + Observation observation = mock(Observation.class); + given(this.observationRegistry.getCurrentObservation()).willReturn(observation); + Supplier one = mock(Supplier.class); + this.tested + .securityContextChanged(new SecurityContextChangedEvent(one, SecurityContextChangedEvent.NO_CONTEXT)); + ArgumentCaptor event = ArgumentCaptor.forClass(Observation.Event.class); + verify(observation).event(event.capture()); + assertThat(event.getValue().getName()).isEqualTo("security.context.cleared"); + verifyNoInteractions(one); + } + + @Test + void securityContextChangedWhenNoChangeThenNoEventAddedToObservation() { + Observation observation = mock(Observation.class); + given(this.observationRegistry.getCurrentObservation()).willReturn(observation); + this.tested.securityContextChanged(new SecurityContextChangedEvent(this.one, this.one)); + verifyNoInteractions(observation); + } + + @Test + void securityContextChangedWhenChangedEventThenAddsChangeEventToObservation() { + Observation observation = mock(Observation.class); + given(this.observationRegistry.getCurrentObservation()).willReturn(observation); + this.tested.securityContextChanged(new SecurityContextChangedEvent(this.one, this.two)); + ArgumentCaptor event = ArgumentCaptor.forClass(Observation.Event.class); + verify(observation).event(event.capture()); + assertThat(event.getValue().getName()).isEqualTo("security.context.changed"); + } + + @Test + void securityContextChangedWhenCreatedEventThenAddsCreatedEventToObservation() { + Observation observation = mock(Observation.class); + given(this.observationRegistry.getCurrentObservation()).willReturn(observation); + this.tested.securityContextChanged(new SecurityContextChangedEvent(null, this.one)); + ArgumentCaptor event = ArgumentCaptor.forClass(Observation.Event.class); + verify(observation).event(event.capture()); + assertThat(event.getValue().getName()).isEqualTo("security.context.created"); + } + +} From 46ab84684b6ea2babfd1684a450b4930b81c4a34 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Fri, 30 Sep 2022 11:08:23 -0600 Subject: [PATCH 5/7] Mark Observations with CSRF Failures Closes gh-11993 --- .../web/configurers/CsrfConfigurer.java | 19 +++++++ .../config/http/CsrfBeanDefinitionParser.java | 19 ++++++- .../config/http/HttpConfigurationBuilder.java | 32 ++++++++++-- .../HttpSecurityBeanDefinitionParser.java | 3 +- .../access/CompositeAccessDeniedHandler.java | 50 +++++++++++++++++++ ...ObservationMarkingAccessDeniedHandler.java | 46 +++++++++++++++++ 6 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/access/CompositeAccessDeniedHandler.java create mode 100644 web/src/main/java/org/springframework/security/web/access/ObservationMarkingAccessDeniedHandler.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java index 83c62b7bf1d..54009892e2e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java @@ -20,6 +20,7 @@ import java.util.LinkedHashMap; import java.util.List; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationContext; @@ -29,7 +30,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; +import org.springframework.security.web.access.CompositeAccessDeniedHandler; import org.springframework.security.web.access.DelegatingAccessDeniedHandler; +import org.springframework.security.web.access.ObservationMarkingAccessDeniedHandler; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; import org.springframework.security.web.csrf.CsrfAuthenticationStrategy; import org.springframework.security.web.csrf.CsrfFilter; @@ -221,6 +224,11 @@ public void configure(H http) { filter.setRequireCsrfProtectionMatcher(requireCsrfProtectionMatcher); } AccessDeniedHandler accessDeniedHandler = createAccessDeniedHandler(http); + ObservationRegistry registry = getObservationRegistry(); + if (!registry.isNoop()) { + ObservationMarkingAccessDeniedHandler observable = new ObservationMarkingAccessDeniedHandler(registry); + accessDeniedHandler = new CompositeAccessDeniedHandler(observable, accessDeniedHandler); + } if (accessDeniedHandler != null) { filter.setAccessDeniedHandler(accessDeniedHandler); } @@ -331,6 +339,17 @@ private SessionAuthenticationStrategy getSessionAuthenticationStrategy() { return csrfAuthenticationStrategy; } + private ObservationRegistry getObservationRegistry() { + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + String[] names = context.getBeanNamesForType(ObservationRegistry.class); + if (names.length == 1) { + return context.getBean(ObservationRegistry.class); + } + else { + return ObservationRegistry.NOOP; + } + } + /** * Allows registering {@link RequestMatcher} instances that should be ignored (even if * the {@link HttpServletRequest} matches the diff --git a/config/src/main/java/org/springframework/security/config/http/CsrfBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/CsrfBeanDefinitionParser.java index 686db75721e..cdf62504a52 100644 --- a/config/src/main/java/org/springframework/security/config/http/CsrfBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/CsrfBeanDefinitionParser.java @@ -36,7 +36,9 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.CompositeAccessDeniedHandler; import org.springframework.security.web.access.DelegatingAccessDeniedHandler; +import org.springframework.security.web.access.ObservationMarkingAccessDeniedHandler; import org.springframework.security.web.csrf.CsrfAuthenticationStrategy; import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CsrfLogoutHandler; @@ -80,6 +82,8 @@ public class CsrfBeanDefinitionParser implements BeanDefinitionParser { private String requestHandlerRef; + private BeanMetadataElement observationRegistry; + @Override public BeanDefinition parse(Element element, ParserContext pc) { boolean disabled = element != null && "true".equals(element.getAttribute("disabled")); @@ -160,7 +164,16 @@ private BeanMetadataElement createAccessDeniedHandler(BeanDefinition invalidSess .rootBeanDefinition(DelegatingAccessDeniedHandler.class); deniedBldr.addConstructorArgValue(handlers); deniedBldr.addConstructorArgValue(defaultDeniedHandler); - return deniedBldr.getBeanDefinition(); + BeanDefinition denied = deniedBldr.getBeanDefinition(); + ManagedList compositeList = new ManagedList(); + BeanDefinitionBuilder compositeBldr = BeanDefinitionBuilder + .rootBeanDefinition(CompositeAccessDeniedHandler.class); + BeanDefinition observing = BeanDefinitionBuilder.rootBeanDefinition(ObservationMarkingAccessDeniedHandler.class) + .addConstructorArgValue(this.observationRegistry).getBeanDefinition(); + compositeList.add(denied); + compositeList.add(observing); + compositeBldr.addConstructorArgValue(compositeList); + return compositeBldr.getBeanDefinition(); } BeanDefinition getCsrfAuthenticationStrategy() { @@ -195,6 +208,10 @@ void setIgnoreCsrfRequestMatchers(List requestMatchers) { } } + void setObservationRegistry(BeanMetadataElement observationRegistry) { + this.observationRegistry = observationRegistry; + } + private static final class DefaultRequiresCsrfMatcher implements RequestMatcher { private final HashSet allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS")); diff --git a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java index 6c99b790c6f..fdacf36e494 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpConfigurationBuilder.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +import io.micrometer.observation.ObservationRegistry; import jakarta.servlet.ServletRequest; import org.w3c.dom.Element; @@ -106,6 +107,8 @@ class HttpConfigurationBuilder { private static final String ATT_INVALID_SESSION_URL = "invalid-session-url"; + private static final String ATT_OBSERVATION_REGISTRY_REF = "observation-registry-ref"; + private static final String ATT_SESSION_AUTH_STRATEGY_REF = "session-authentication-strategy-ref"; private static final String ATT_SESSION_AUTH_ERROR_URL = "session-authentication-error-url"; @@ -211,7 +214,7 @@ class HttpConfigurationBuilder { private boolean addAllAuth; HttpConfigurationBuilder(Element element, boolean addAllAuth, ParserContext pc, BeanReference portMapper, - BeanReference portResolver, BeanReference authenticationManager) { + BeanReference portResolver, BeanReference authenticationManager, BeanMetadataElement observationRegistry) { this.httpElt = element; this.addAllAuth = addAllAuth; this.pc = pc; @@ -226,7 +229,7 @@ class HttpConfigurationBuilder { createSecurityContextHolderStrategy(); createForceEagerSessionCreationFilter(); createDisableEncodeUrlFilter(); - createCsrfFilter(); + createCsrfFilter(observationRegistry); createSecurityPersistence(); createSessionManagementFilters(); createWebAsyncManagerFilter(); @@ -812,9 +815,10 @@ private void createDisableEncodeUrlFilter() { } } - private void createCsrfFilter() { + private void createCsrfFilter(BeanMetadataElement observationRegistry) { Element elmt = DomUtils.getChildElementByTagName(this.httpElt, Elements.CSRF); this.csrfParser = new CsrfBeanDefinitionParser(); + this.csrfParser.setObservationRegistry(observationRegistry); this.csrfFilter = this.csrfParser.parse(elmt, this.pc); if (this.csrfFilter == null) { this.csrfParser = null; @@ -897,6 +901,14 @@ List getFilters() { return filters; } + private static BeanMetadataElement getObservationRegistry(Element httpElmt) { + String holderStrategyRef = httpElmt.getAttribute(ATT_OBSERVATION_REGISTRY_REF); + if (StringUtils.hasText(holderStrategyRef)) { + return new RuntimeBeanReference(holderStrategyRef); + } + return BeanDefinitionBuilder.rootBeanDefinition(ObservationRegistryFactory.class).getBeanDefinition(); + } + static class RoleVoterBeanFactory extends AbstractGrantedAuthorityDefaultsBeanFactory { private RoleVoter voter = new RoleVoter(); @@ -944,4 +956,18 @@ public Class getObjectType() { } + static class ObservationRegistryFactory implements FactoryBean { + + @Override + public ObservationRegistry getObject() throws Exception { + return ObservationRegistry.NOOP; + } + + @Override + public Class getObjectType() { + return ObservationRegistry.class; + } + + } + } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java index 4ff9599d88c..53bc876d7c5 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java @@ -150,8 +150,9 @@ private BeanReference createFilterChain(Element element, ParserContext pc) { ManagedList authenticationProviders = new ManagedList<>(); BeanReference authenticationManager = createAuthenticationManager(element, pc, authenticationProviders); boolean forceAutoConfig = isDefaultHttpConfig(element); + BeanMetadataElement observationRegistry = getObservationRegistry(element); HttpConfigurationBuilder httpBldr = new HttpConfigurationBuilder(element, forceAutoConfig, pc, portMapper, - portResolver, authenticationManager); + portResolver, authenticationManager, observationRegistry); httpBldr.getSecurityContextRepositoryForAuthenticationFilters(); AuthenticationConfigBuilder authBldr = new AuthenticationConfigBuilder(element, forceAutoConfig, pc, httpBldr.getSessionCreationPolicy(), httpBldr.getRequestCache(), authenticationManager, diff --git a/web/src/main/java/org/springframework/security/web/access/CompositeAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/access/CompositeAccessDeniedHandler.java new file mode 100644 index 00000000000..6ff843846ec --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/access/CompositeAccessDeniedHandler.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.web.access; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.access.AccessDeniedException; + +public final class CompositeAccessDeniedHandler implements AccessDeniedHandler { + + private Collection handlers; + + public CompositeAccessDeniedHandler(AccessDeniedHandler... handlers) { + this(Arrays.asList(handlers)); + } + + public CompositeAccessDeniedHandler(Collection handlers) { + this.handlers = new ArrayList<>(handlers); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + for (AccessDeniedHandler handler : this.handlers) { + handler.handle(request, response, accessDeniedException); + } + } + +} diff --git a/web/src/main/java/org/springframework/security/web/access/ObservationMarkingAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/access/ObservationMarkingAccessDeniedHandler.java new file mode 100644 index 00000000000..ceeff2ecbf0 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/access/ObservationMarkingAccessDeniedHandler.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.web.access; + +import java.io.IOException; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.access.AccessDeniedException; + +public final class ObservationMarkingAccessDeniedHandler implements AccessDeniedHandler { + + private final ObservationRegistry registry; + + public ObservationMarkingAccessDeniedHandler(ObservationRegistry registry) { + this.registry = registry; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) + throws IOException, ServletException { + Observation observation = this.registry.getCurrentObservation(); + if (observation != null) { + observation.error(exception); + } + } + +} From 2713075d0801b121134e1f678d0acf78adba0f8f Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 11 Oct 2022 17:44:29 -0600 Subject: [PATCH 6/7] Mark Observations with Firewall Failures Closes gh-11994 --- .../annotation/web/builders/WebSecurity.java | 6 +++ .../HttpFirewallBeanDefinitionParser.java | 2 +- .../HttpSecurityBeanDefinitionParser.java | 22 ++++++++-- ...ervationMarkingRequestRejectedHandler.java | 44 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/firewall/ObservationMarkingRequestRejectedHandler.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java index c86f054c7ee..fad2eab83a5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/WebSecurity.java @@ -57,6 +57,7 @@ import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.debug.DebugFilter; import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.ObservationMarkingRequestRejectedHandler; import org.springframework.security.web.firewall.RequestRejectedHandler; import org.springframework.security.web.firewall.StrictHttpFirewall; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -307,6 +308,10 @@ protected Filter performBuild() throws Exception { if (this.requestRejectedHandler != null) { filterChainProxy.setRequestRejectedHandler(this.requestRejectedHandler); } + else if (!this.observationRegistry.isNoop()) { + filterChainProxy + .setRequestRejectedHandler(new ObservationMarkingRequestRejectedHandler(this.observationRegistry)); + } filterChainProxy.setFilterChainDecorator(getFilterChainDecorator()); filterChainProxy.afterPropertiesSet(); @@ -319,6 +324,7 @@ protected Filter performBuild() throws Exception { + "********************************************************************\n\n"); result = new DebugFilter(filterChainProxy); } + this.postBuildAction.run(); return result; } diff --git a/config/src/main/java/org/springframework/security/config/http/HttpFirewallBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HttpFirewallBeanDefinitionParser.java index 2a166c662de..4de28385210 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpFirewallBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpFirewallBeanDefinitionParser.java @@ -40,7 +40,7 @@ public BeanDefinition parse(Element element, ParserContext pc) { pc.getReaderContext().error("ref attribute is required", pc.extractSource(element)); } // Ensure the FCP is registered. - HttpSecurityBeanDefinitionParser.registerFilterChainProxyIfNecessary(pc, pc.extractSource(element)); + HttpSecurityBeanDefinitionParser.registerFilterChainProxyIfNecessary(pc, element); BeanDefinition filterChainProxy = pc.getRegistry().getBeanDefinition(BeanIds.FILTER_CHAIN_PROXY); filterChainProxy.getPropertyValues().addPropertyValue("firewall", new RuntimeBeanReference(ref)); return null; diff --git a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java index 53bc876d7c5..c6b12017dad 100644 --- a/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HttpSecurityBeanDefinitionParser.java @@ -58,6 +58,7 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.ObservationFilterChainDecorator; import org.springframework.security.web.PortResolverImpl; +import org.springframework.security.web.firewall.ObservationMarkingRequestRejectedHandler; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.util.StringUtils; import org.springframework.util.xml.DomUtils; @@ -120,7 +121,7 @@ public BeanDefinition parse(Element element, ParserContext pc) { CompositeComponentDefinition compositeDef = new CompositeComponentDefinition(element.getTagName(), pc.extractSource(element)); pc.pushContainingComponent(compositeDef); - registerFilterChainProxyIfNecessary(pc, pc.extractSource(element)); + registerFilterChainProxyIfNecessary(pc, element); // Obtain the filter chains and add the new chain to it BeanDefinition listFactoryBean = pc.getRegistry().getBeanDefinition(BeanIds.FILTER_CHAINS); List filterChains = (List) listFactoryBean.getPropertyValues() @@ -351,7 +352,8 @@ else if (StringUtils.hasText(before)) { return customFilters; } - static void registerFilterChainProxyIfNecessary(ParserContext pc, Object source) { + static void registerFilterChainProxyIfNecessary(ParserContext pc, Element element) { + Object source = pc.extractSource(element); BeanDefinitionRegistry registry = pc.getRegistry(); if (registry.containsBeanDefinition(BeanIds.FILTER_CHAIN_PROXY)) { return; @@ -378,6 +380,7 @@ static void registerFilterChainProxyIfNecessary(ParserContext pc, Object source) requestRejected.addConstructorArgValue("requestRejectedHandler"); requestRejected.addConstructorArgValue(BeanIds.FILTER_CHAIN_PROXY); requestRejected.addConstructorArgValue("requestRejectedHandler"); + requestRejected.addPropertyValue("observationRegistry", getObservationRegistry(element)); AbstractBeanDefinition requestRejectedBean = requestRejected.getBeanDefinition(); String requestRejectedPostProcessorName = pc.getReaderContext().generateBeanName(requestRejectedBean); registry.registerBeanDefinition(requestRejectedPostProcessorName, requestRejectedBean); @@ -391,7 +394,7 @@ private static BeanMetadataElement getObservationRegistry(Element methodSecurity return BeanDefinitionBuilder.rootBeanDefinition(ObservationRegistryFactory.class).getBeanDefinition(); } - static class RequestRejectedHandlerPostProcessor implements BeanDefinitionRegistryPostProcessor { + public static class RequestRejectedHandlerPostProcessor implements BeanDefinitionRegistryPostProcessor { private final String beanName; @@ -399,6 +402,8 @@ static class RequestRejectedHandlerPostProcessor implements BeanDefinitionRegist private final String targetPropertyName; + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + RequestRejectedHandlerPostProcessor(String beanName, String targetBeanName, String targetPropertyName) { this.beanName = beanName; this.targetBeanName = targetBeanName; @@ -412,6 +417,13 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t beanDefinition.getPropertyValues().add(this.targetPropertyName, new RuntimeBeanReference(this.beanName)); } + else if (!this.observationRegistry.isNoop()) { + BeanDefinition observable = BeanDefinitionBuilder + .rootBeanDefinition(ObservationMarkingRequestRejectedHandler.class) + .addConstructorArgValue(this.observationRegistry).getBeanDefinition(); + BeanDefinition beanDefinition = registry.getBeanDefinition(this.targetBeanName); + beanDefinition.getPropertyValues().add(this.targetPropertyName, observable); + } } @Override @@ -419,6 +431,10 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) } + public void setObservationRegistry(ObservationRegistry registry) { + this.observationRegistry = registry; + } + } /** diff --git a/web/src/main/java/org/springframework/security/web/firewall/ObservationMarkingRequestRejectedHandler.java b/web/src/main/java/org/springframework/security/web/firewall/ObservationMarkingRequestRejectedHandler.java new file mode 100644 index 00000000000..0f9eac70fc7 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/firewall/ObservationMarkingRequestRejectedHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2022 the original author or 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 org.springframework.security.web.firewall; + +import java.io.IOException; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public final class ObservationMarkingRequestRejectedHandler implements RequestRejectedHandler { + + private final ObservationRegistry registry; + + public ObservationMarkingRequestRejectedHandler(ObservationRegistry registry) { + this.registry = registry; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, RequestRejectedException exception) + throws IOException, ServletException { + Observation observation = this.registry.getCurrentObservation(); + if (observation != null) { + observation.error(exception); + } + } + +} From fe96a62dfc6205273aad133ed9aee38eb42e9908 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Tue, 4 Oct 2022 15:34:16 -0600 Subject: [PATCH 7/7] Document Observability Support Issue gh-10964 --- docs/modules/ROOT/nav.adoc | 2 + .../reactive/integrations/observability.adoc | 230 +++++++++++++++++ .../namespace/authentication-manager.adoc | 3 + .../servlet/appendix/namespace/http.adoc | 3 + .../appendix/namespace/method-security.adoc | 4 + .../servlet/integrations/observability.adoc | 235 ++++++++++++++++++ docs/modules/ROOT/pages/whats-new.adoc | 5 + 7 files changed, 482 insertions(+) create mode 100644 docs/modules/ROOT/pages/reactive/integrations/observability.adoc create mode 100644 docs/modules/ROOT/pages/servlet/integrations/observability.adoc diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index d33f0929009..7769a742e2d 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -91,6 +91,7 @@ *** xref:servlet/integrations/websocket.adoc[WebSocket] *** xref:servlet/integrations/cors.adoc[Spring's CORS Support] *** xref:servlet/integrations/jsp-taglibs.adoc[JSP Taglib] +*** xref:servlet/integrations/observability.adoc[Observability] ** Configuration *** xref:servlet/configuration/java.adoc[Java Configuration] *** xref:servlet/configuration/kotlin.adoc[Kotlin Configuration] @@ -147,6 +148,7 @@ ** Integrations *** xref:reactive/integrations/cors.adoc[CORS] *** xref:reactive/integrations/rsocket.adoc[RSocket] +*** xref:reactive/integrations/observability.adoc[Observability] ** xref:reactive/test/index.adoc[Testing] *** xref:reactive/test/method.adoc[Testing Method Security] *** xref:reactive/test/web/index.adoc[Testing Web Security] diff --git a/docs/modules/ROOT/pages/reactive/integrations/observability.adoc b/docs/modules/ROOT/pages/reactive/integrations/observability.adoc new file mode 100644 index 00000000000..706b15e9cb9 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/integrations/observability.adoc @@ -0,0 +1,230 @@ +[[webflux-observability]] += Observability + +Spring Security integrates with Spring Observability out-of-the-box for tracing; though it's also quite simple to configure for gathering metrics. + +[[webflux-observability-tracing]] +== Tracing + +When an `ObservationRegistry` bean is present, Spring Security creates traces for: + +* the filter chain +* the `ReactiveAuthenticationManager`, and +* the `ReactiveAuthorizationManager` + +[[webflux-observability-tracing-boot]] +=== Boot Integration + +For example, consider a simple Boot application: + +==== +.Java +[source,java,role="primary"] +---- +@SpringBootApplication +public class MyApplication { + @Bean + public ReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + ObservationRegistryCustomizer addTextHandler() { + return (registry) -> registry.observationConfig().observationHandler(new ObservationTextHandler()); + } + + public static void main(String[] args) { + SpringApplication.run(ListenerSamplesApplication.class, args); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@SpringBootApplication +class MyApplication { + @Bean + fun userDetailsService(): ReactiveUserDetailsService { + MapReactiveUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + fun addTextHandler(): ObservationRegistryCustomizer { + return registry: ObservationRegistry -> registry.observationConfig() + .observationHandler(ObservationTextHandler()); + } + + fun main(args: Array) { + runApplication(*args) + } +} +---- +==== + +And a corresponding request: + +==== +[source,bash] +---- +?> http -a user:password :8080 +---- +==== + +Will produce the following output (indentation added for clarity): + +==== +[source,bash] +---- +START - name='http.server.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@5dfdb78', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.00191856, duration(nanos)=1918560.0, startTimeNanos=101177265022745}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@121549e0'] + START - name='spring.security.http.chains', contextualName='spring.security.http.chains.before', error='null', lowCardinalityKeyValues=[chain.size='14', filter.section='before'], highCardinalityKeyValues=[request.line='/'], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@3932a48c', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=4.65777E-4, duration(nanos)=465777.0, startTimeNanos=101177276300777}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@562db70f'] + STOP - name='spring.security.http.chains', contextualName='spring.security.http.chains.before', error='null', lowCardinalityKeyValues=[chain.size='14', filter.section='before'], highCardinalityKeyValues=[request.line='/'], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@3932a48c', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.003733105, duration(nanos)=3733105.0, startTimeNanos=101177276300777}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@562db70f'] + START - name='spring.security.authentications', contextualName='null', error='null', lowCardinalityKeyValues=[authentication.failure.type='Optional', authentication.method='UserDetailsRepositoryReactiveAuthenticationManager', authentication.request.type='UsernamePasswordAuthenticationToken'], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@574ba6cd', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=3.21015E-4, duration(nanos)=321015.0, startTimeNanos=101177336038417}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@49202cc7'] + STOP - name='spring.security.authentications', contextualName='null', error='null', lowCardinalityKeyValues=[authentication.failure.type='Optional', authentication.method='UserDetailsRepositoryReactiveAuthenticationManager', authentication.request.type='UsernamePasswordAuthenticationToken', authentication.result.type='UsernamePasswordAuthenticationToken'], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@574ba6cd', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.37574992, duration(nanos)=3.7574992E8, startTimeNanos=101177336038417}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@49202cc7'] + START - name='spring.security.authorizations', contextualName='null', error='null', lowCardinalityKeyValues=[object.type='SecurityContextServerWebExchange'], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@6f837332', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=2.65687E-4, duration(nanos)=265687.0, startTimeNanos=101177777941381}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@7f5bc7cb'] + STOP - name='spring.security.authorizations', contextualName='null', error='null', lowCardinalityKeyValues=[authorization.decision='true', object.type='SecurityContextServerWebExchange'], highCardinalityKeyValues=[authentication.authorities='[app]', authorization.decision.details='AuthorizationDecision [granted=true]'], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@6f837332', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.039239047, duration(nanos)=3.9239047E7, startTimeNanos=101177777941381}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@7f5bc7cb'] + START - name='spring.security.http.secured.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@2f33dfae', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=3.1775E-4, duration(nanos)=317750.0, startTimeNanos=101177821377592}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@63b0d28f'] + STOP - name='spring.security.http.secured.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@2f33dfae', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.219901971, duration(nanos)=2.19901971E8, startTimeNanos=101177821377592}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@63b0d28f'] + START - name='spring.security.http.chains', contextualName='spring.security.http.chains.after', error='null', lowCardinalityKeyValues=[chain.size='14', filter.section='after'], highCardinalityKeyValues=[request.line='/'], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@40b25623', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=3.25118E-4, duration(nanos)=325118.0, startTimeNanos=101178044824275}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@3b6cec2'] + STOP - name='spring.security.http.chains', contextualName='spring.security.http.chains.after', error='null', lowCardinalityKeyValues=[chain.size='14', filter.section='after'], highCardinalityKeyValues=[request.line='/'], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@40b25623', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.001693146, duration(nanos)=1693146.0, startTimeNanos=101178044824275}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@3b6cec2'] +STOP - name='http.server.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.tracing.handler.TracingObservationHandler$TracingContext='io.micrometer.tracing.handler.TracingObservationHandler$TracingContext@5dfdb78', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.784320641, duration(nanos)=7.84320641E8, startTimeNanos=101177265022745}', class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@121549e0'] +---- +==== + +[[webflux-observability-tracing-manual-configuration]] +=== Manual Configuration + +For a non-Spring Boot application, or to override the existing Boot configuration, you can publish your own `ObservationRegistry` and Spring Security will still pick it up. + +==== +.Java +[source,java,role="primary"] +---- +@SpringBootApplication +public class MyApplication { + @Bean + public ReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + ObservationRegistry observationRegistry() { + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(new ObservationTextHandler()); + return registry; + } + + public static void main(String[] args) { + SpringApplication.run(ListenerSamplesApplication.class, args); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@SpringBootApplication +class MyApplication { + @Bean + fun userDetailsService(): ReactiveUserDetailsService { + MapReactiveUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + fun observationRegistry(): ObservationRegistry { + ObservationRegistry registry = ObservationRegistry.create() + registry.observationConfig().observationHandler(ObservationTextHandler()) + return registry + } + + fun main(args: Array) { + runApplication(*args) + } +} +---- + +.Xml +[source,kotlin,role="secondary"] +---- + + + + + +---- +==== + +[[webflux-observability-tracing-disable]] +=== Disabling Observability + +If you don't want any Spring Security observations, in a Spring Boot application you can publish a `ObservationRegistry.NOOP` `@Bean`. +However, this may turn off observations for more than just Spring Security. + +Instead, you can alter the provided `ObservationRegistry` with an `ObservationPredicate` like the following: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ObservationRegistryCustomizer noSpringSecurityObservations() { + ObservationPredicate predicate = (name, context) -> name.startsWith("spring.security.") + return (registry) -> registry.observationConfig().observationPredicate(predicate) +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun noSpringSecurityObservations(): ObservationRegistryCustomizer { + ObservationPredicate predicate = (name: String, context: Observation.Context) -> name.startsWith("spring.security.") + (registry: ObservationRegistry) -> registry.observationConfig().observationPredicate(predicate) +} +---- +==== + +[TIP] +There is no facility for disabling observations with XML support. +Instead, simply do not set the `observation-registry-ref` attribute. + +[[webflux-observability-tracing-listing]] +=== Trace Listing + +Spring Security tracks the following spans on each request: + +1. `spring.security.http.requests` - a span that wraps the entire filter chain, including the request +2. `spring.security.http.chains.before` - a span that wraps the receiving part of the security filters +3. `spring.security.http.chains.after` - a span that wraps the returning part of the security filters +4. `spring.security.http.secured.requests` - a span that wraps the now-secured application request +5. `spring.security.http.unsecured.requests` - a span that wraps requests that Spring Security does not secure +6. `spring.security.authentications` - a span that wraps authentication attempts +7. `spring.security.authorizations` - a span that wraps authorization attempts + +[TIP] +`spring.security.http.chains.before` + `spring.security.http.secured.requests` + `spring.security.http.chains.after` = `spring.security.http.requests` +`spring.security.http.chains.before` + `spring.security.http.chains.after` = Spring Security's part of the request diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/authentication-manager.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/authentication-manager.adoc index 5452a2b7994..3719fe7394c 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/authentication-manager.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/authentication-manager.adoc @@ -27,6 +27,9 @@ This attribute allows you to define an alias name for the internal instance for If set to true, the AuthenticationManager will attempt to clear any credentials data in the returned Authentication object, once the user has been authenticated. Literally it maps to the `eraseCredentialsAfterAuthentication` property of the xref:servlet/authentication/architecture.adoc#servlet-authentication-providermanager[`ProviderManager`]. +[[nsa-authentication-manager-observation-registry-ref]] +* **observation-registry-ref** +A reference to the `ObservationRegistry` used for the `FilterChain` and related components [[nsa-authentication-manager-id]] * **id** diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc index 29de571e37e..a6ad46b43a3 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/http.adoc @@ -47,6 +47,9 @@ By default an `AffirmativeBased` implementation is used for with a `RoleVoter` a * **authentication-manager-ref** A reference to the `AuthenticationManager` used for the `FilterChain` created by this http element. +[[nsa-http-observation-registry-ref]] +* **observation-registry-ref** +A reference to the `ObservationRegistry` used for the `FilterChain` and related components [[nsa-http-auto-config]] * **auto-config** diff --git a/docs/modules/ROOT/pages/servlet/appendix/namespace/method-security.adoc b/docs/modules/ROOT/pages/servlet/appendix/namespace/method-security.adoc index 4224ce93bef..71b8d771504 100644 --- a/docs/modules/ROOT/pages/servlet/appendix/namespace/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/appendix/namespace/method-security.adoc @@ -37,6 +37,10 @@ Defaults to "false". Specifies a SecurityContextHolderStrategy to use when retrieving the SecurityContext. Defaults to the value returned by SecurityContextHolder.getContextHolderStrategy(). +[[nsa-method-security-observation-registry-ref]] +* **observation-registry-ref** +A reference to the `ObservationRegistry` used for the `FilterChain` and related components + [[nsa-method-security-children]] === Child Elements of diff --git a/docs/modules/ROOT/pages/servlet/integrations/observability.adoc b/docs/modules/ROOT/pages/servlet/integrations/observability.adoc new file mode 100644 index 00000000000..63bc157f7dc --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/integrations/observability.adoc @@ -0,0 +1,235 @@ +[[observability]] += Observability + +Spring Security integrates with Spring Observability out-of-the-box for tracing; though it's also quite simple to configure for gathering metrics. + +[[observability-tracing]] +== Tracing + +When an `ObservationRegistry` bean is present, Spring Security creates traces for: + +* the filter chain +* the `AuthenticationManager`, and +* the `AuthorizationManager` + +[[observability-tracing-boot]] +=== Boot Integration + +For example, consider a simple Boot application: + +==== +.Java +[source,java,role="primary"] +---- +@SpringBootApplication +public class MyApplication { + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + ObservationRegistryCustomizer addTextHandler() { + return (registry) -> registry.observationConfig().observationHandler(new ObservationTextHandler()); + } + + public static void main(String[] args) { + SpringApplication.run(ListenerSamplesApplication.class, args); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@SpringBootApplication +class MyApplication { + @Bean + fun userDetailsService(): UserDetailsService { + InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + fun addTextHandler(): ObservationRegistryCustomizer { + return registry: ObservationRegistry -> registry.observationConfig() + .observationHandler(ObservationTextHandler()); + } + + fun main(args: Array) { + runApplication(*args) + } +} +---- +==== + +And a corresponding request: + +==== +[source,bash] +---- +?> http -a user:password :8080 +---- +==== + +Will produce the following output (indentation added for clarity): + +==== +[source,bash] +---- +START - name='http.server.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@687e16d1', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.001779024, duration(nanos)=1779024.0, startTimeNanos=91695917264958}'] + START - name='spring.security.http.chains', contextualName='spring.security.http.chains.before', error='null', lowCardinalityKeyValues=[chain.position='0', chain.size='17', filter.section='before'], highCardinalityKeyValues=[request.line='GET /'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@79f554a5', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=7.42147E-4, duration(nanos)=742147.0, startTimeNanos=91695947182029}'] + ... skipped for brevity ... + STOP - name='spring.security.http.chains', contextualName='spring.security.http.chains.before', error='null', lowCardinalityKeyValues=[chain.position='0', chain.size='17', filter.section='before'], highCardinalityKeyValues=[request.line='GET /'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@79f554a5', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.014771848, duration(nanos)=1.4771848E7, startTimeNanos=91695947182029}'] + START - name='spring.security.authentications', contextualName='null', error='null', lowCardinalityKeyValues=[authentication.failure.type='Optional', authentication.method='ProviderManager', authentication.request.type='UsernamePasswordAuthenticationToken'], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@4d4b2b56', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=7.09759E-4, duration(nanos)=709759.0, startTimeNanos=91696094477504}'] + ... skipped for brevity ... + STOP - name='spring.security.authentications', contextualName='null', error='null', lowCardinalityKeyValues=[authentication.failure.type='Optional', authentication.method='ProviderManager', authentication.request.type='UsernamePasswordAuthenticationToken', authentication.result.type='UsernamePasswordAuthenticationToken'], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@4d4b2b56', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.895141386, duration(nanos)=8.95141386E8, startTimeNanos=91696094477504}'] + START - name='spring.security.authorizations', contextualName='null', error='null', lowCardinalityKeyValues=[object.type='Servlet3SecurityContextHolderAwareRequestWrapper'], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@6d834cc7', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=3.0965E-4, duration(nanos)=309650.0, startTimeNanos=91697034893983}'] + ... skipped for brevity ... + STOP - name='spring.security.authorizations', contextualName='null', error='null', lowCardinalityKeyValues=[authorization.decision='true', object.type='Servlet3SecurityContextHolderAwareRequestWrapper'], highCardinalityKeyValues=[authentication.authorities='[app]', authorization.decision.details='AuthorizationDecision [granted=true]'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@6d834cc7', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.02084809, duration(nanos)=2.084809E7, startTimeNanos=91697034893983}'] + START - name='spring.security.http.secured.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@649c5ec3', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=2.67878E-4, duration(nanos)=267878.0, startTimeNanos=91697059819304}'] + ... skipped for brevity ... + STOP - name='spring.security.http.secured.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@649c5ec3', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.090753322, duration(nanos)=9.0753322E7, startTimeNanos=91697059819304}'] + START - name='spring.security.http.chains', contextualName='spring.security.http.chains.after', error='null', lowCardinalityKeyValues=[chain.position='0', chain.size='17', filter.section='after'], highCardinalityKeyValues=[request.line='GET /'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@47af8207', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=5.31832E-4, duration(nanos)=531832.0, startTimeNanos=91697152857268}'] + ... skipped for brevity ... + STOP - name='spring.security.http.chains', contextualName='spring.security.http.chains.after', error='null', lowCardinalityKeyValues=[chain.position='17', chain.size='17', current.filter.name='DisableEncodeUrlFilter', filter.section='after'], highCardinalityKeyValues=[request.line='GET /'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@47af8207', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=0.007689382, duration(nanos)=7689382.0, startTimeNanos=91697152857268}'] +STOP - name='http.server.requests', contextualName='null', error='null', lowCardinalityKeyValues=[], highCardinalityKeyValues=[request.line='GET /'], map=[class io.micrometer.core.instrument.Timer$Sample='io.micrometer.core.instrument.Timer$Sample@687e16d1', class io.micrometer.core.instrument.LongTaskTimer$Sample='SampleImpl{duration(seconds)=1.245858319, duration(nanos)=1.245858319E9, startTimeNanos=91695917264958}'] +---- +==== + +[[observability-tracing-manual-configuration]] +=== Manual Configuration + +For a non-Spring Boot application, or to override the existing Boot configuration, you can publish your own `ObservationRegistry` and Spring Security will still pick it up. + +==== +.Java +[source,java,role="primary"] +---- +@SpringBootApplication +public class MyApplication { + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + ObservationRegistry observationRegistry() { + ObservationRegistry registry = ObservationRegistry.create(); + registry.observationConfig().observationHandler(new ObservationTextHandler()); + return registry; + } + + public static void main(String[] args) { + SpringApplication.run(ListenerSamplesApplication.class, args); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@SpringBootApplication +class MyApplication { + @Bean + fun userDetailsService(): UserDetailsService { + InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + fun observationRegistry(): ObservationRegistry { + ObservationRegistry registry = ObservationRegistry.create() + registry.observationConfig().observationHandler(ObservationTextHandler()) + return registry + } + + fun main(args: Array) { + runApplication(*args) + } +} +---- + +.Xml +[source,kotlin,role="secondary"] +---- + + + + + +---- +==== + +[[observability-tracing-disable]] +==== Disabling Observability + +If you don't want any Spring Security observations, in a Spring Boot application you can publish a `ObservationRegistry.NOOP` `@Bean`. +However, this may turn off observations for more than just Spring Security. + +Instead, you can alter the provided `ObservationRegistry` with an `ObservationPredicate` like the following: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ObservationRegistryCustomizer noSpringSecurityObservations() { + ObservationPredicate predicate = (name, context) -> name.startsWith("spring.security.") + return (registry) -> registry.observationConfig().observationPredicate(predicate) +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun noSpringSecurityObservations(): ObservationRegistryCustomizer { + ObservationPredicate predicate = (name: String, context: Observation.Context) -> name.startsWith("spring.security.") + (registry: ObservationRegistry) -> registry.observationConfig().observationPredicate(predicate) +} +---- +==== + +[TIP] +There is no facility for disabling observations with XML support. +Instead, simply do not set the `observation-registry-ref` attribute. + +[[observability-tracing-listing]] +=== Trace Listing + +Spring Security tracks the following spans on each request: + +1. `spring.security.http.requests` - a span that wraps the entire filter chain, including the request +2. `spring.security.http.chains.before` - a span that wraps the receiving part of the security filters +3. `spring.security.http.chains.after` - a span that wraps the returning part of the security filters +4. `spring.security.http.secured.requests` - a span that wraps the now-secured application request +5. `spring.security.http.unsecured.requests` - a span that wraps requests that Spring Security does not secure +6. `spring.security.authentications` - a span that wraps authentication attempts +7. `spring.security.authorizations` - a span that wraps authorization attempts + +[TIP] +`spring.security.http.chains.before` + `spring.security.http.secured.requests` + `spring.security.http.chains.after` = `spring.security.http.requests` +`spring.security.http.chains.before` + `spring.security.http.chains.after` = Spring Security's part of the request diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 95e29aab97f..5224da329b2 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -29,3 +29,8 @@ Or use `use-authorization-manager="false"` * https://github.com/spring-projects/spring-security/issues/11939[gh-11939] - Remove deprecated `antMatchers`, `mvcMatchers`, `regexMatchers` helper methods from Java Configuration. Instead, use `requestMatchers` or `HttpSecurity#securityMatchers`. * https://github.com/spring-projects/spring-security/issues/11985[gh-11985] - Remove deprecated constructors in `Argon2PasswordEncoder`, `SCryptPasswordEncoder` and `Pbkdf2PasswordEncoder`. + +== Observability + +* xref:servlet/integrations/observability.adoc[Instrumentation] of `AuthenticationManager`, `AuthorizationManager`, and `FilterChainProxy` +* xref:reactive/integrations/observability.adoc[Instrumentation] of `ReactiveAuthenticationManager`, `ReactiveAuthorizationManager`, and `WebFilterChainProxy`