Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -93,40 +60,27 @@ 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<Set<RoleRepresentation>>()
.filter { it.name != null }
.first { it.name.equals(role.toString(), true) }

// 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<Role> {
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<Set<RoleRepresentation>>()

Expand All @@ -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<UserRepresentation>()

Expand Down Expand Up @@ -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)
Expand All @@ -189,26 +136,18 @@ class KeycloakFacade(

builder.build()
}
.headers {
it.setBearerAuth(token!!)
}
.bodyValue(requiredActions)
.retrieve()
.awaitBodilessEntity()
}

override suspend fun updateAccount(account: Account, requiredActions: List<RequiredAction>): 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()
Expand All @@ -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(
Expand All @@ -238,9 +175,6 @@ class KeycloakFacade(
)

val magicLinkResponse = resourceClient.post().uri("/magic-link")
.headers {
it.setBearerAuth(token!!)
}
.bodyValue(request)
.retrieve()
.awaitBody<MagicLinkResponse>()
Expand All @@ -249,14 +183,9 @@ class KeycloakFacade(
}

private suspend fun queryAll(query: String): List<Account> {
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<List<UserRepresentation>>()

Expand All @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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
}
}
26 changes: 20 additions & 6 deletions src/main/resources/config/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,33 @@ spring:

main:
banner-mode: off
allow-bean-definition-overriding: true
web-application-type: reactive

flyway:
enabled: true

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:
Expand All @@ -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
Expand Down