From 6e8de7817e059918784e9969b23bb43f748e4bf5 Mon Sep 17 00:00:00 2001 From: Felix Klauke Date: Sat, 9 Jul 2022 23:04:35 +0200 Subject: [PATCH] Add spring jwt user provider Add spring jwt user provider test --- buildSrc/src/main/java/Config.kt | 1 + sentry-spring-boot-starter/build.gradle.kts | 2 + .../spring/boot/SentryAutoConfiguration.java | 22 +++++ .../boot/SentrySpringJwtUserProvider.java | 76 ++++++++++++++++ .../boot/SentryAutoConfigurationTest.kt | 22 ++++- .../boot/SentrySpringJwtUserProviderTest.kt | 89 +++++++++++++++++++ 6 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySpringJwtUserProvider.java create mode 100644 sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringJwtUserProviderTest.kt diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 1c6f5c7ea6..d8dc6eabbd 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -73,6 +73,7 @@ object Config { val springBootStarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBootVersion" val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion" val springBootStarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBootVersion" + val springBootStartOauth2ResourceServer = "org.springframework.boot:spring-boot-starter-oauth2-resource-server:$springBootVersion" val springWeb = "org.springframework:spring-webmvc" val springWebflux = "org.springframework:spring-webflux" diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index bca7c8daa6..a76ead65a3 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { compileOnly(Config.Libs.servletApi) compileOnly(Config.Libs.springBootStarterAop) compileOnly(Config.Libs.springBootStarterSecurity) + compileOnly(Config.Libs.springBootStartOauth2ResourceServer) compileOnly(Config.Libs.reactorCore) annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure) @@ -66,6 +67,7 @@ dependencies { testImplementation(Config.Libs.springBootStarterWeb) testImplementation(Config.Libs.springBootStarterWebflux) testImplementation(Config.Libs.springBootStarterSecurity) + testImplementation(Config.Libs.springBootStartOauth2ResourceServer) testImplementation(Config.Libs.springBootStarterAop) } diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index effc521500..ef406ce76e 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -51,6 +51,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.servlet.HandlerExceptionResolver; @@ -166,6 +167,27 @@ static class SentrySecurityConfiguration { } } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(Jwt.class) + @Open + static class SentryJwtConfiguration { + + /** + * Configures {@link SentrySpringJwtUserProvider} only if Spring Jwt is on the + * classpath. Its order is set to be higher than {@link + * SentrySecurityConfiguration#springSecuritySentryUserProvider(SentryOptions)} + * + * @param sentryOptions the Sentry options + * @return {@link SentrySpringJwtUserProvider} + */ + @Bean + @Order(2) + public @NotNull SentrySpringJwtUserProvider springJwtUserProvider( + final @NotNull SentryOptions sentryOptions) { + return new SentrySpringJwtUserProvider(sentryOptions); + } + } + /** * Configures {@link SentryUserFilter}. By default it runs as the last filter in order to make * sure that all potential authentication information is propagated to {@link diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySpringJwtUserProvider.java b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySpringJwtUserProvider.java new file mode 100644 index 0000000000..f6bf2e1c2f --- /dev/null +++ b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySpringJwtUserProvider.java @@ -0,0 +1,76 @@ +package io.sentry.spring.boot; + +import io.sentry.SentryOptions; +import io.sentry.protocol.User; +import io.sentry.spring.SentryUserProvider; +import io.sentry.util.Objects; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +public final class SentrySpringJwtUserProvider implements SentryUserProvider { + private final @NotNull SentryOptions options; + + public SentrySpringJwtUserProvider(final @NotNull SentryOptions options) { + this.options = Objects.requireNonNull(options, "options is required"); + } + + @Override + public @Nullable User provideUser() { + if (options.isSendDefaultPii()) { + final Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principal instanceof Jwt) { + return extraJwtUser((Jwt) principal); + } else { + return null; + } + } + return null; + } + + private User extraJwtUser(Jwt jwt) { + final User user = new User(); + user.setId(jwt.getSubject()); + user.setEmail(jwt.getClaimAsString("email")); + user.setUsername(findJwtUsername(jwt)); + user.setOthers(findJwtInfo(jwt)); + return user; + } + + private @Nullable String findJwtUsername(Jwt jwt) { + if (jwt.hasClaim("username")) { + return jwt.getClaimAsString("username"); + } else if (jwt.hasClaim("preferred_username")) { + return jwt.getClaimAsString("preferred_username"); + } else if (jwt.hasClaim("name")) { + return jwt.getClaimAsString("name"); + } else if (jwt.hasClaim("email")) { + return jwt.getClaimAsString("email"); + } else { + return null; + } + } + + private Map findJwtInfo(Jwt jwt) { + final Map info = new HashMap<>(); + if (jwt.hasClaim("iss")) { + info.put("iss", jwt.getClaimAsString("iss")); + } + if (jwt.hasClaim("aud")) { + info.put("aud", jwt.getClaimAsString("aud")); + } + if (jwt.hasClaim("exp")) { + info.put("exp", jwt.getClaimAsString("exp")); + } + if (jwt.hasClaim("iat")) { + info.put("iat", jwt.getClaimAsString("iat")); + } + if (jwt.hasClaim("scope")) { + info.put("scope", jwt.getClaimAsString("scope")); + } + return info; + } +} diff --git a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 8d29c72670..4bd32e4d1c 100644 --- a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -49,6 +49,7 @@ import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.client.RestTemplate import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.servlet.HandlerExceptionResolver @@ -328,10 +329,11 @@ class SentryAutoConfigurationTest { .withConfiguration(UserConfigurations.of(SentryUserProviderConfiguration::class.java)) .run { val userProviders = it.getSentryUserProviders() - assertEquals(3, userProviders.size) + assertEquals(4, userProviders.size) assertTrue(userProviders[0] is HttpServletRequestSentryUserProvider) assertTrue(userProviders[1] is SpringSecuritySentryUserProvider) - assertTrue(userProviders[2] is CustomSentryUserProvider) + assertTrue(userProviders[2] is SentrySpringJwtUserProvider) + assertTrue(userProviders[3] is CustomSentryUserProvider) } } @@ -341,10 +343,11 @@ class SentryAutoConfigurationTest { .withConfiguration(UserConfigurations.of(SentryHighestOrderUserProviderConfiguration::class.java)) .run { val userProviders = it.getSentryUserProviders() - assertEquals(3, userProviders.size) + assertEquals(4, userProviders.size) assertTrue(userProviders[0] is CustomSentryUserProvider) assertTrue(userProviders[1] is HttpServletRequestSentryUserProvider) assertTrue(userProviders[2] is SpringSecuritySentryUserProvider) + assertTrue(userProviders[3] is SentrySpringJwtUserProvider) } } @@ -361,6 +364,19 @@ class SentryAutoConfigurationTest { } } + @Test + fun `when Jwt is not on the classpath, SentrySpringJwtUserProvider is not configured`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withClassLoader(FilteredClassLoader(Jwt::class.java)) + .run { ctx -> + val userProviders = ctx.getSentryUserProviders() + assertTrue(userProviders.isNotEmpty()) + userProviders.forEach { + assertFalse(it is SentrySpringJwtUserProvider) + } + } + } + @Test fun `when Spring MVC is not on the classpath, SentryExceptionResolver is not configured`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") diff --git a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringJwtUserProviderTest.kt b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringJwtUserProviderTest.kt new file mode 100644 index 0000000000..48a452d1fc --- /dev/null +++ b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringJwtUserProviderTest.kt @@ -0,0 +1,89 @@ +package io.sentry.spring.boot + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.SentryOptions +import org.junit.jupiter.api.Test +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt +import java.time.Instant +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SentrySpringJwtUserProviderTest { + + class Fixture { + fun getSut(isSendDefaultPii: Boolean = true, jwt: Jwt? = null): SentrySpringJwtUserProvider { + val options = SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + } + val securityContext = mock() + if (jwt != null) { + val authentication = mock() + whenever(securityContext.authentication).thenReturn(authentication) + whenever(authentication.principal).thenReturn(jwt) + } else { + whenever(securityContext.authentication).thenReturn(null) + } + SecurityContextHolder.setContext(securityContext) + return SentrySpringJwtUserProvider(options) + } + + fun getJwt(tokenValue: String, subject: String, issuer: String, issuedAt: Instant, expiresAt: Instant, audience: Collection, headers: Map, claims: Map): Jwt { + return Jwt.withTokenValue(tokenValue) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .subject(subject) + .issuer(issuer) + .audience(audience) + .claims { + it.putAll(claims) + } + .headers { + it.putAll(headers) + } + .build() + } + } + + private val fixture = Fixture() + + @Test + fun `when send default pii is set to true, returns fully populated user`() { + val jwt = fixture.getJwt( + tokenValue = "token", + subject = "subject", + issuer = "issuer", + issuedAt = Instant.MIN, + expiresAt = Instant.MAX, + audience = listOf("audience"), + headers = mapOf("header" to "value"), + claims = mapOf("email" to "info@sentry.io") + ) + val provider = fixture.getSut(true, jwt) + val user = provider.provideUser() + assertNotNull(user) { + assertEquals("subject", it.username) + assertEquals("email", "info@sentry.io") + assertEquals("subject", it.id) + assertEquals(jwt.claims, it.others) + } + } + + @Test + fun `when send default pii is set to false, returns null`() { + val provider = fixture.getSut(false) + val user = provider.provideUser() + assertNull(user) + } + + @Test + fun `when send default pii is set to true and security context is not set, returns null`() { + val provider = fixture.getSut(true) + val user = provider.provideUser() + assertNull(user) + } +}