diff --git a/src/main/kotlin/dk/cachet/carp/webservices/security/authentication/oauth2/issuers/keycloak/KeycloakFacade.kt b/src/main/kotlin/dk/cachet/carp/webservices/security/authentication/oauth2/issuers/keycloak/KeycloakFacade.kt index 36de5d1c..90277edf 100644 --- a/src/main/kotlin/dk/cachet/carp/webservices/security/authentication/oauth2/issuers/keycloak/KeycloakFacade.kt +++ b/src/main/kotlin/dk/cachet/carp/webservices/security/authentication/oauth2/issuers/keycloak/KeycloakFacade.kt @@ -1,6 +1,5 @@ package dk.cachet.carp.webservices.security.authentication.oauth2.issuers.keycloak -import com.fasterxml.jackson.databind.ObjectMapper import dk.cachet.carp.common.application.UUID import dk.cachet.carp.common.application.users.AccountIdentity import dk.cachet.carp.common.application.users.EmailAccountIdentity @@ -17,12 +16,7 @@ import org.apache.logging.log4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.PropertySource import org.springframework.context.annotation.PropertySources -import org.springframework.http.MediaType -import org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE -import org.springframework.http.codec.json.Jackson2JsonDecoder -import org.springframework.http.codec.json.Jackson2JsonEncoder import org.springframework.stereotype.Service -import org.springframework.web.reactive.function.client.ExchangeStrategies import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBodilessEntity import org.springframework.web.reactive.function.client.awaitBody @@ -32,56 +26,29 @@ import org.springframework.web.util.UriBuilder @Service @PropertySources(PropertySource(value = ["classpath:config/application-\${spring.profiles.active}.yml"])) class KeycloakFacade( - @Value("\${keycloak.auth-server-url}") private val authServerUrl: String, - @Value("\${keycloak.realm}") private val realm: String, - @Value("\${keycloak.admin.client-id}") private val clientId: String, - @Value("\${keycloak.admin.client-secret}") private val clientSecret: String, - private val objectMapper: ObjectMapper, - private val environmentUtil: EnvironmentUtil -) : IssuerFacade { - companion object { + @Value("\${keycloak.issuer-url}") private val issuerUrl: String, + @Value("\${keycloak.admin-url}") private val adminUrl: String, + @Value("\${keycloak.client-id}") private val clientId: String, + private val environmentUtil: EnvironmentUtil, + webClientBuilder: WebClient.Builder // see WebClientConfig.kt +) : IssuerFacade +{ + companion object + { private val LOGGER: Logger = LogManager.getLogger() private const val INVITATION_LIFESPAN = 24 * 60 * 60 * 30 // 30 days } - private val serializationStrategies: ExchangeStrategies = - ExchangeStrategies.builder() - .codecs { configurer -> - configurer.defaultCodecs() - .jackson2JsonEncoder( - Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON) - ) - configurer.defaultCodecs() - .jackson2JsonDecoder( - Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON) - ) - } - .build() - - private val adminClient: WebClient = buildWebClient("$authServerUrl/admin/realms/$realm") - - private val resourceClient: WebClient = buildWebClient("$authServerUrl/realms/$realm") - - private val authClient: WebClient = buildWebClient("$authServerUrl/realms/$realm") - .mutate().defaultHeaders { - it.contentType = MediaType.parseMediaType(APPLICATION_FORM_URLENCODED_VALUE) - it.accept = listOf(MediaType.APPLICATION_JSON) - it.setBasicAuth(clientId, clientSecret) - }.build() - + private val adminClient: WebClient = webClientBuilder.baseUrl(issuerUrl).build() + private val resourceClient: WebClient = webClientBuilder.baseUrl(adminUrl).build() override suspend fun createAccount(account: Account, accountType: AccountType): Account { - val token = authenticate().accessToken - LOGGER.debug("Creating account {}", account) val userRepresentation = UserRepresentation .createFromAccount(account, RequiredAction.getForAccountType(accountType)) adminClient.post().uri("/users") - .headers { - it.setBearerAuth(token!!) - } .bodyValue(userRepresentation) .retrieve() .awaitBodilessEntity() @@ -93,16 +60,11 @@ class KeycloakFacade( } override suspend fun addRole(account: Account, role: Role) { - val token = authenticate().accessToken - LOGGER.debug("Updating role of account: {}", account) // getting role representation with id val roleRepresentation: RoleRepresentation = adminClient.get().uri("/roles") - .headers { - it.setBearerAuth(token!!) - } .retrieve() .awaitBody>() .filter { it.name != null } @@ -110,23 +72,15 @@ class KeycloakFacade( // adding role to account adminClient.post().uri("/users/${account.id}/role-mappings/realm") - .headers { - it.setBearerAuth(token!!) - } .bodyValue(listOf(roleRepresentation)) .retrieve() .awaitBodilessEntity() } override suspend fun getRoles(id: UUID): Set { - val token = authenticate().accessToken - LOGGER.debug("Getting roles of account with id: {}", id) val roleRepresentations = adminClient.get().uri("/users/${id}/role-mappings/realm") - .headers { - it.setBearerAuth(token!!) - } .retrieve() .awaitBody>() @@ -135,14 +89,9 @@ class KeycloakFacade( override suspend fun getAccount(uuid: UUID): Account? { - val token = authenticate().accessToken - LOGGER.debug("Getting account with id: {}", uuid) val userRepresentation = adminClient.get().uri("/users/${uuid}") - .headers { - it.setBearerAuth(token!!) - } .retrieve() .awaitBody() @@ -171,8 +120,6 @@ class KeycloakFacade( } override suspend fun sendInvitation(account: Account, redirectUri: String?, accountType: AccountType) { - val token = authenticate().accessToken - LOGGER.debug("Sending invitation to account with id: ${account.id}") val requiredActions = RequiredAction.getForAccountType(accountType) @@ -189,26 +136,18 @@ class KeycloakFacade( builder.build() } - .headers { - it.setBearerAuth(token!!) - } .bodyValue(requiredActions) .retrieve() .awaitBodilessEntity() } override suspend fun updateAccount(account: Account, requiredActions: List): Account { - val token = authenticate().accessToken - LOGGER.debug("Updating account: {}", account) val userRepresentation = UserRepresentation .createFromAccount(account, requiredActions) adminClient.put().uri("/users/${account.id}") - .headers { - it.setBearerAuth(token!!) - } .bodyValue(userRepresentation) .retrieve() .awaitBodilessEntity() @@ -225,8 +164,6 @@ class KeycloakFacade( redirectUri: String?, expirationSeconds: Long?, ): String { - val token = authenticate().accessToken - LOGGER.debug("Generating recovery link for account: {}", account) val request = MagicLinkRequest( @@ -238,9 +175,6 @@ class KeycloakFacade( ) val magicLinkResponse = resourceClient.post().uri("/magic-link") - .headers { - it.setBearerAuth(token!!) - } .bodyValue(request) .retrieve() .awaitBody() @@ -249,14 +183,9 @@ class KeycloakFacade( } private suspend fun queryAll(query: String): List { - val token = authenticate().accessToken - LOGGER.debug("Querying all accounts with query: {}", query) val userRepresentations = adminClient.get().uri("/users?$query") - .headers { - it.setBearerAuth(token!!) - } .retrieve() .awaitBody>() @@ -265,19 +194,4 @@ class KeycloakFacade( userRepresentation.toAccount(roles) } } - - suspend fun authenticate(): TokenResponse = - authClient.post().uri("/protocol/openid-connect/token") - .bodyValue("grant_type=client_credentials") - .retrieve() - .awaitBody() - - private fun buildWebClient(baseUrl: String): WebClient = WebClient.builder() - .baseUrl(baseUrl) - .exchangeStrategies(serializationStrategies) - .defaultHeaders { - it.contentType = MediaType.APPLICATION_JSON - it.accept = listOf(MediaType.APPLICATION_JSON) - } - .build() } \ No newline at end of file diff --git a/src/main/kotlin/dk/cachet/carp/webservices/security/config/WebClientConfig.kt b/src/main/kotlin/dk/cachet/carp/webservices/security/config/WebClientConfig.kt new file mode 100644 index 00000000..549ff102 --- /dev/null +++ b/src/main/kotlin/dk/cachet/carp/webservices/security/config/WebClientConfig.kt @@ -0,0 +1,68 @@ +package dk.cachet.carp.webservices.security.config + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.http.codec.json.Jackson2JsonDecoder +import org.springframework.http.codec.json.Jackson2JsonEncoder +import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction +import org.springframework.web.reactive.function.client.ExchangeStrategies +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class WebClientConfig +{ + @Bean + fun defaultExchangeStrategies( + objectMapper: ObjectMapper + ) = ExchangeStrategies.builder() + .codecs { + it.defaultCodecs().jackson2JsonEncoder( Jackson2JsonEncoder( objectMapper, MediaType.APPLICATION_JSON ) ) + it.defaultCodecs().jackson2JsonDecoder( Jackson2JsonDecoder( objectMapper, MediaType.APPLICATION_JSON ) ) + }.build() + + @Bean + fun defaultWebClientBuilder( + authorizedClientManager: ReactiveOAuth2AuthorizedClientManager, + defaultExchangeStrategies: ExchangeStrategies + ): WebClient.Builder + { + val authorizedClientFilter = ServerOAuth2AuthorizedClientExchangeFilterFunction( authorizedClientManager ) + + return WebClient.builder() + .exchangeStrategies( defaultExchangeStrategies ) + .defaultHeaders { + it.contentType = MediaType.APPLICATION_JSON + it.accept = listOf(MediaType.APPLICATION_JSON) + } + .filter( authorizedClientFilter ) + } + + @Bean + fun authorizedClientManager( + clientRegistrationRepository: ReactiveClientRegistrationRepository, + authorizedClientService: ReactiveOAuth2AuthorizedClientService + ): ReactiveOAuth2AuthorizedClientManager + { + val authorizedClientManager = + AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( + clientRegistrationRepository, + authorizedClientService + ) + + authorizedClientManager.setAuthorizedClientProvider( + ReactiveOAuth2AuthorizedClientProviderBuilder.builder() + .clientCredentials() + .build() + ) + + return authorizedClientManager + } +} \ No newline at end of file diff --git a/src/main/resources/config/application-local.yml b/src/main/resources/config/application-local.yml index 2a059fca..020197b5 100644 --- a/src/main/resources/config/application-local.yml +++ b/src/main/resources/config/application-local.yml @@ -104,6 +104,8 @@ spring: main: banner-mode: off + allow-bean-definition-overriding: true + web-application-type: reactive flyway: enabled: true @@ -111,12 +113,24 @@ spring: jackson: default-property-inclusion: NON_NULL + security: + oauth2: + client: + registration: + carp-webservices: + provider: keycloak + client-name: CARP WebServices + client-id: "${KC_CLIENT_ID}" + client-secret: "${KC_CLIENT_SECRET}" + authorization-grant-type: client_credentials + provider: + keycloak: + issuer-uri: ${keycloak.issuer-url} + keycloak: - auth-server-url: "${KC_URL}" - realm: Carp - admin: - client-id: "${KC_CLIENT_ID}" - client-secret: "${KC_CLIENT_SECRET}" + issuer-url: "${KC_URL}/realms/Carp" + admin-url: "${KC_URL}/admin/realms/Carp" + client-id: "${KC_CLIENT_ID}" com: c4-soft: @@ -131,7 +145,7 @@ com: - path: /** allowed-origins: "*" ops: - - iss: ${keycloak.auth-server-url}/realms/${keycloak.realm} + - iss: ${keycloak.issuer-url} username-claim: preferred_username authorities: - path: $.realm_access.roles