首页 > 解决方案 > Spring Boot Data JPA @CreatedBy 和 @UpdatedBy 未填充使用 OIDC 进行身份验证

问题描述

我想让 Spring JPA 审计与 Spring Boot 一起使用,我正在使用 Spring Security 的最新功能通过 Keycloak 进行身份验证。

springBootVersion = '2.1.0.RC1'

我正在关注 Spring Security 团队的示例https://github.com/jzheaux/messaging-app/tree/springone2018-demo/resource-server

资源服务器配置.kt

@EnableWebSecurity
class OAuth2ResourceServerSecurityConfiguration(val resourceServerProperties: OAuth2ResourceServerProperties) : WebSecurityConfigurerAdapter() {

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http
                .authorizeRequests()
                .antMatchers("/api/**").authenticated()
                .anyRequest().anonymous()
                .and()
                .oauth2ResourceServer()
                .authenticationEntryPoint(MoreInformativeAuthenticationEntryPoint())
                .jwt()
                .jwtAuthenticationConverter(GrantedAuthoritiesExtractor())
                .decoder(jwtDecoder())

    }

    private fun jwtDecoder(): JwtDecoder {
        val issuerUri = this.resourceServerProperties.jwt.issuerUri

        val jwtDecoder = JwtDecoders.fromOidcIssuerLocation(issuerUri) as NimbusJwtDecoderJwkSupport

        val withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri)
        val withAudience = DelegatingOAuth2TokenValidator(withIssuer, AudienceValidator())
        jwtDecoder.setJwtValidator(withAudience)

        return jwtDecoder
    }
}

class MoreInformativeAuthenticationEntryPoint : AuthenticationEntryPoint {
    private val delegate = BearerTokenAuthenticationEntryPoint()

    private val mapper = ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)

    @Throws(IOException::class, ServletException::class)
    override fun commence(request: HttpServletRequest, response: HttpServletResponse,
                          reason: AuthenticationException) {

        this.delegate.commence(request, response, reason)

        if (reason.cause is JwtValidationException) {
            val validationException = reason.cause as JwtValidationException
            val errors = validationException.errors
            this.mapper.writeValue(response.writer, errors)
        }
    }
}

class GrantedAuthoritiesExtractor : JwtAuthenticationConverter() {
    override fun extractAuthorities(jwt: Jwt): Collection<GrantedAuthority> {
        val scopes = jwt.claims["scope"].toString().split(" ")
        return scopes.map { SimpleGrantedAuthority(it) }
    }
}

class AudienceValidator : OAuth2TokenValidator<Jwt> {

    override fun validate(token: Jwt): OAuth2TokenValidatorResult {
        val audience = token.audience
        return if (!CollectionUtils.isEmpty(audience) && audience.contains("mobile-client")) {
            OAuth2TokenValidatorResult.success()
        } else {
            OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE)
        }
    }

    companion object {
        private val MISSING_AUDIENCE = BearerTokenError("invalid_token", HttpStatus.UNAUTHORIZED,
                "The token is missing a required audience.", null)
    }
}

应用程序.yaml

spring:
  application:
    name: sociter
  datasource:
    url: jdbc:postgresql://localhost:5432/sociter
    username: postgres
    password: 123123
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8080/auth/realms/sociter/protocol/openid-connect/certs
          issuer-uri: http://localhost:8080/auth/realms/sociter

JpaAuditingConfiguration.kt

@Configuration
@EnableJpaAuditing
(auditorAwareRef = "auditorProvider")
class JpaAuditingConfiguration {

    @Bean
    fun auditorProvider(): AuditorAware<String> {
        return if (SecurityContextHolder.getContext().authentication != null) {
            val oauth2 = SecurityContextHolder.getContext().authentication as JwtAuthenticationToken
        val claims = oauth2.token.claims
        val userId = claims["sub"]
        AuditorAware { Optional.of(userId.toString()) }
        } else
            AuditorAware { Optional.of("Unknown") }
    }
}

BaseEntity.kt

@MappedSuperclass
@JsonIgnoreProperties(value = ["createdOn, updatedOn"], allowGetters = true)
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id: UUID = UUID.randomUUID()

    @Column(nullable = false, updatable = false)
    @CreatedDate
    var createdOn: LocalDateTime = LocalDateTime.now()

    @Column(nullable = true)
    @LastModifiedDate
    var updatedOn: LocalDateTime? = null

    @Column(nullable = true, updatable = false)
    @CreatedBy
    var createdBy: String? = null

    @Column(nullable = true)
    @LastModifiedBy
    var updatedBy: String? = null
}

我将 createdBy 和 UpdatedBy 设置为未知。在调试期间,auditorProvider bean 被调用并将用户设置为未知,但在传递 access_token 时,如果条件仍然为假。

不知道我错过了什么。

标签: spring-bootkotlinspring-security-oauth2keycloak

解决方案


I was able to replicate your issue, but in an equivalent Java setup. The issue is in your JpaAuditingConfiguration class. If you observe your current JpaAuditingConfiguration class closely this is what happens there:

  1. During Spring initialization the auditorProvider() function will try to generate a bean.
  2. The authentication condition is being checked there upfront(during application startup) and this thread(which starts the Spring Boot App) is NOT an authenticated thread at all. Hence it returns an AuditorAware instance that will always return Unknown.

You need to change this class as follows(Sorry, I wrote it in Java, please convert it to Kotlin):

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JPAAuditConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {
        return new AuditorAware<String>() {
            @Override
            public String getCurrentAuditor() {
                if (SecurityContextHolder.getContext().getAuthentication() != null) {
                    OAuth2Authentication auth = (OAuth2Authentication) SecurityContextHolder.getContext().getAuthentication();
                    Object principal = auth.getUserAuthentication().getPrincipal();
                    CustomUserDetails userDetails = (CustomUserDetails) principal;
                    return userDetails.getUsername();
                } else {
                    return "Unknown";
                }
            }
        };
    }
}

You can try this. Also, I suspect that with your current setup you would get updatedOn and createdOn correctly populated. If yes, that means all the JPA and EntityListener magic is actually working. You just need to return the correct implementation of AuditorAware at runtime.

Also note that, my config does not use JwtAuthenticationToken and I use a CustomUserDetails implementation. But that's not related to your problem, and you can of course use your current token type (JwtAuthenticationToken). Its just that, I had my own little app up and running inside which I replicated your issue.


推荐阅读