From 4efd1201a997632b6ad7c9e0b7e79f85474a45ec Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 1 Dec 2022 16:01:23 +0100 Subject: [PATCH 001/120] Use ktor instead of okhttp --- gradle.properties | 2 + managementportal-client/build.gradle | 22 +- .../management/auth/AuthTokenHolder.kt | 51 ++++ .../auth/ClientCredentialsConfig.kt | 24 ++ .../management/auth/MPOAuth2AccessToken.kt | 22 ++ .../management/auth/OAuthClientProvider.kt | 162 ++++++++++ .../radarbase/management/client/MPClient.kt | 280 ++++++++---------- .../management/client/MPOAuth2AccessToken.kt | 28 -- .../management/client/MPOAuthClient.kt | 7 +- .../management/client/MPOrganization.kt | 6 +- .../radarbase/management/client/MPProject.kt | 15 +- .../org/radarbase/management/client/MPRole.kt | 6 +- .../management/client/MPSourceData.kt | 3 + .../management/client/MPSourceType.kt | 3 + .../radarbase/management/client/MPSubject.kt | 9 +- .../org/radarbase/management/client/MPUser.kt | 13 +- .../management/client/MPClientTest.kt | 57 +++- 17 files changed, 476 insertions(+), 234 deletions(-) create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt delete mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuth2AccessToken.kt diff --git a/gradle.properties b/gradle.properties index a1f44e5fe..1a5100eef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,6 +26,8 @@ oauth_jwt_version=4.2.1 junit_version=5.9.1 okhttp_version=4.10.0 hsqldb_version=2.7.1 +coroutines_version=1.6.4 +ktor_version=2.1.3 kotlin.code.style=official org.gradle.vfs.watch=true diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index 4079955ff..d92ed2f71 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -9,7 +9,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * See the file LICENSE in the root of this repository. */ plugins { - id 'org.jetbrains.kotlin.jvm' version "1.7.20" + id 'org.jetbrains.kotlin.jvm' version "1.7.22" + id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.22' id 'org.jetbrains.dokka' version "1.7.20" id 'maven-publish' } @@ -20,17 +21,19 @@ targetCompatibility = JavaVersion.VERSION_11 description = "Kotlin ManagementPortal client" dependencies { - api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.20") + api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22") + implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.22") api(project(":oauth-client-util")) - implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: okhttp_version - - implementation(platform("com.fasterxml.jackson:jackson-bom:$jackson_version")) - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") - runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) + api(platform("io.ktor:ktor-bom:$ktor_version")) + api("io.ktor:ktor-client-core") + implementation("io.ktor:ktor-client-auth") + implementation("io.ktor:ktor-client-cio") + implementation("io.ktor:ktor-client-content-negotiation") + implementation("io.ktor:ktor-serialization-kotlinx-json") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' testImplementation group: 'org.mockito', name: 'mockito-core', version: mockito_version @@ -38,7 +41,6 @@ dependencies { testRuntimeOnly group: 'org.slf4j', name: 'slf4j-simple', version: slf4j_version testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version - testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin") } tasks.withType(KotlinCompile) { diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt new file mode 100644 index 000000000..38a720cc8 --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt @@ -0,0 +1,51 @@ +package org.radarbase.management.auth + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.atomic.AtomicReference + +internal class AuthTokenHolder( + private val loadTokens: suspend () -> T? +) { + private val refreshTokensDeferred = AtomicReference?>(null) + private val loadTokensDeferred = AtomicReference?>(null) + + internal fun clearToken() { + loadTokensDeferred.set(null) + refreshTokensDeferred.set(null) + } + + internal suspend fun loadToken(): T? { + var deferred: CompletableDeferred? + do { + deferred = loadTokensDeferred.get() + val newValue = deferred ?: CompletableDeferred() + } while (!loadTokensDeferred.compareAndSet(deferred, newValue)) + + return if (deferred != null) { + deferred.await() + } else { + val newTokens = loadTokens() + loadTokensDeferred.get()!!.complete(newTokens) + newTokens + } + } + + internal suspend fun setToken(block: suspend () -> T?): T? { + var deferred: CompletableDeferred? + do { + deferred = refreshTokensDeferred.get() + val newValue = deferred ?: CompletableDeferred() + } while (!refreshTokensDeferred.compareAndSet(deferred, newValue)) + + val newToken = if (deferred == null) { + val newTokens = block() + refreshTokensDeferred.get()!!.complete(newTokens) + refreshTokensDeferred.set(null) + newTokens + } else { + deferred.await() + } + loadTokensDeferred.set(CompletableDeferred(newToken)) + return newToken + } +} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt new file mode 100644 index 000000000..c063908ff --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt @@ -0,0 +1,24 @@ +package org.radarbase.management.auth + +data class ClientCredentialsConfig( + val tokenUrl: String, + val clientId: String? = null, + val clientSecret: String? = null, +) { + /** + * Fill in the client ID and client secret from environment variables. The variables are + * `<prefix>_CLIENT_ID` and `<prefix>_CLIENT_SECRET`. + */ + fun copyWithEnv(prefix: String = "MANAGEMENT_PORTAL"): ClientCredentialsConfig { + var result = this + val envClientId = System.getenv("${prefix}_CLIENT_ID") + if (envClientId != null) { + result = result.copy(clientId = envClientId) + } + val envClientSecret = System.getenv("${prefix}_CLIENT_SECRET") + if (envClientSecret != null) { + result = result.copy(clientSecret = envClientSecret) + } + return result + } +} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt new file mode 100644 index 000000000..bcdc961f1 --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021. The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * See the file LICENSE in the root of this repository. + */ + +package org.radarbase.management.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MPOAuth2AccessToken( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("expires_in") val expiresIn: Long = 0, + @SerialName("token_type") val tokenType: String? = null, + @SerialName("user_id") val externalUserId: String? = null, +) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt new file mode 100644 index 000000000..d8c050803 --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt @@ -0,0 +1,162 @@ +package org.radarbase.management.auth + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.auth.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.* + +/** + * Installs the client's [BearerAuthProvider]. + */ +fun Auth.clientCredentials(block: ClientCredentialsAuthConfig.() -> Unit) { + with(ClientCredentialsAuthConfig().apply(block)) { + this@clientCredentials.providers.add(ClientCredentialsAuthProvider(_requestToken, _loadTokens, _sendWithoutRequest, realm)) + } +} + +fun Auth.clientCredentials( + authConfig: ClientCredentialsConfig, + targetHost: String? = null, + emit: suspend (MPOAuth2AccessToken?) -> Unit = {}, +) { + requireNotNull(authConfig.clientId) { "Missing client ID" } + requireNotNull(authConfig.clientSecret) { "Missing client secret"} + + clientCredentials { + if (targetHost != null) { + sendWithoutRequest { request -> + request.url.host == targetHost + } + } + requestToken { + val refreshTokenInfo: MPOAuth2AccessToken = client.submitForm { + url(authConfig.tokenUrl) + formData { + append("grant_type", "client_credentials") + append("client_id", authConfig.clientId) + append("client_secret", authConfig.clientSecret) + } + markAsRequestTokenRequest() + }.body() + emit(refreshTokenInfo) + refreshTokenInfo + } + } +} + +/** + * Parameters to be passed to [BearerAuthConfig.refreshTokens] lambda. + */ +class RequestTokenParams( + val client: HttpClient, +) { + /** + * Marks that this request is for requesting auth tokens, resulting in a special handling of it. + */ + fun HttpRequestBuilder.markAsRequestTokenRequest() { + attributes.put(Auth.AuthCircuitBreaker, Unit) + } +} + +/** + * A configuration for [BearerAuthProvider]. + */ +@KtorDsl +class ClientCredentialsAuthConfig { + internal var _requestToken: suspend RequestTokenParams.() -> MPOAuth2AccessToken? = { null } + internal var _loadTokens: suspend () -> MPOAuth2AccessToken? = { null } + internal var _sendWithoutRequest: (HttpRequestBuilder) -> Boolean = { true } + + var realm: String? = null + + /** + * Configures a callback that refreshes a token when the 401 status code is received. + */ + fun requestToken(block: suspend RequestTokenParams.() -> MPOAuth2AccessToken?) { + _requestToken = block + } + + /** + * Configures a callback that loads a cached token from a local storage. + * Note: Using the same client instance here to make a request will result in a deadlock. + */ + fun loadTokens(block: suspend () -> MPOAuth2AccessToken?) { + _loadTokens = block + } + + /** + * Sends credentials without waiting for [HttpStatusCode.Unauthorized]. + */ + fun sendWithoutRequest(block: (HttpRequestBuilder) -> Boolean) { + _sendWithoutRequest = block + } +} + +/** + * An authentication provider for the Bearer HTTP authentication scheme. + * Bearer authentication involves security tokens called bearer tokens. + * As an example, these tokens can be used as a part of OAuth flow to authorize users of your application + * by using external providers, such as Google, Facebook, Twitter, and so on. + * + * You can learn more from [Bearer authentication](https://ktor.io/docs/bearer-client.html). + */ +class ClientCredentialsAuthProvider( + private val requestToken: suspend RequestTokenParams.() -> MPOAuth2AccessToken?, + loadTokens: suspend () -> MPOAuth2AccessToken?, + private val sendWithoutRequestCallback: (HttpRequestBuilder) -> Boolean = { true }, + private val realm: String?, +) : AuthProvider { + + @Suppress("OverridingDeprecatedMember") + @Deprecated("Please use sendWithoutRequest function instead", replaceWith = ReplaceWith("sendWithoutRequest(request)")) + override val sendWithoutRequest: Boolean + get() = error("Deprecated") + + private val tokensHolder = AuthTokenHolder(loadTokens) + + override fun sendWithoutRequest(request: HttpRequestBuilder): Boolean = sendWithoutRequestCallback(request) + + /** + * Checks if current provider is applicable to the request. + */ + override fun isApplicable(auth: HttpAuthHeader): Boolean { + if (auth.authScheme != AuthScheme.Bearer) return false + if (realm == null) return true + if (auth !is HttpAuthHeader.Parameterized) return false + + return auth.parameter("realm") == realm + } + + /** + * Adds an authentication method headers and credentials. + */ + override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?) { + val token = tokensHolder.loadToken() ?: return + + request.headers { + if (contains(HttpHeaders.Authorization)) { + remove(HttpHeaders.Authorization) + } + append(HttpHeaders.Authorization, "Bearer ${token.accessToken}") + } + } + + override suspend fun refreshToken(response: HttpResponse): Boolean { + val newToken = tokensHolder.setToken { + requestToken(RequestTokenParams(response.call.client)) + } + return newToken != null + } + + fun clearToken() { + tokensHolder.clearToken() + } +} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index 8f5e2c5af..158a571ca 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -9,207 +9,169 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.ObjectReader -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.KotlinFeature -import com.fasterxml.jackson.module.kotlin.jsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.radarbase.oauth.OAuth2AccessTokenDetails -import org.radarbase.oauth.OAuth2Client +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.radarbase.management.auth.MPOAuth2AccessToken import java.io.IOException -import java.net.MalformedURLException -import java.util.concurrent.TimeUnit +import java.time.Duration +import java.util.* + +fun mpClient(config: MPClient.Config.() -> Unit): MPClient { + return MPClient(MPClient.Config().apply(config)) +} /** * Client for the ManagementPortal REST API. */ @Suppress("unused") -class MPClient( - /** Server configuration of the ManagementPortal API. */ - serverConfig: MPServerConfig, - /** ObjectMapper to use for all requests. */ - objectMapper: ObjectMapper? = null, - /** HTTP client to make requests with. */ - httpClient: OkHttpClient? = null, -) { - private val clientId: String = serverConfig.clientId - private val clientSecret: String = serverConfig.clientSecret - val baseUrl: HttpUrl = serverConfig.httpUrl - - private val objectMapper: ObjectMapper = objectMapper ?: jsonMapper { - serializationInclusion(JsonInclude.Include.NON_NULL) - addModule(JavaTimeModule()) - addModule(kotlinModule { - enable(KotlinFeature.NullIsSameAsDefault) - }) - configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } +class MPClient(config: Config) { + private val _token: MutableStateFlow = MutableStateFlow(null) - /** HTTP client to make requests with. */ - var httpClient: OkHttpClient = httpClient ?: OkHttpClient().newBuilder() - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() + val token: Flow = _token - private val organizationListReader: ObjectReader by lazy { this.objectMapper.readerForListOf(MPOrganization::class.java) } - private val projectListReader: ObjectReader by lazy { this.objectMapper.readerForListOf(MPProject::class.java) } - private val subjectListReader: ObjectReader by lazy { this.objectMapper.readerForListOf(MPSubject::class.java) } - private val clientListReader: ObjectReader by lazy { this.objectMapper.readerForListOf(MPOAuthClient::class.java) } + private val url: String = requireNotNull(config.url) { + "Missing server URL" + }.trimEnd('/') + '/' - private val oauth2Client = OAuth2Client.Builder() - .httpClient(httpClient) - .credentials(clientId, clientSecret) - .endpoint(baseUrl.toUrl(), "oauth/token") - .build() + /** HTTP client to make requests with. */ + private val originalHttpClient: HttpClient? = config.httpClient + private val auth: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit = config.auth - /** - * Valid access token for the ManagementPortal REST API. - * @throws org.radarbase.exception.TokenException if a new access token could not be fetched - */ - val validToken: OAuth2AccessTokenDetails - get() = oauth2Client.validToken + val httpClient = (originalHttpClient ?: HttpClient(CIO)).config { + install(HttpTimeout) { + connectTimeoutMillis = Duration.ofSeconds(10).toMillis() + socketTimeoutMillis = Duration.ofSeconds(10).toMillis() + requestTimeoutMillis = Duration.ofSeconds(30).toMillis() + } + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + install(Auth) { + auth(_token::emit) + } + defaultRequest { + url(this@MPClient.url) + } + } /** Request list of organizations from ManagementPortal */ - fun requestOrganizations( + suspend fun requestOrganizations( page: Int = 0, size: Int = Int.MAX_VALUE, - ): List = request( - reader = organizationListReader, - urlBuilder = { - addPathSegments("api/organizations") - addQueryParameter("page", page.toString()) - addQueryParameter("size", size.toString()) + ): List = request { + url("api/organizations") + with(url.parameters) { + append("page", page.toString()) + append("size", size.toString()) } - ) + } /** Request list of projects from ManagementPortal. */ - fun requestProjects( + suspend fun requestProjects( page: Int = 0, size: Int = Int.MAX_VALUE, - ): List = request( - reader = projectListReader, - urlBuilder = { - addPathSegments("api/projects") - addQueryParameter("page", page.toString()) - addQueryParameter("size", size.toString()) - }) + ): List = request { + url("api/projects") + with(url.parameters) { + append("page", page.toString()) + append("size", size.toString()) + } + } /** * Request list of subjects from ManagementPortal project. The [projectId] is the name that * the project is identified by. */ - fun requestSubjects( + suspend fun requestSubjects( projectId: String, page: Int = 0, size: Int = Int.MAX_VALUE, - ): List = request>( - reader = subjectListReader, - urlBuilder = { - addPathSegments("api/projects/$projectId/subjects") - addQueryParameter("page", page.toString()) - addQueryParameter("size", size.toString()) - }) + ): List = request> { + url("api/projects/$projectId/subjects") + with(url.parameters) { + append("page", page.toString()) + append("size", size.toString()) + } + } .map { it.copy(projectId = projectId) } /** * Request list of OAuth 2.0 clients from ManagementPortal. */ - fun requestClients( + suspend fun requestClients( page: Int = 0, size: Int = Int.MAX_VALUE, - ): List = request( - reader = clientListReader, - urlBuilder = { - addPathSegments("api/oauth-clients") - addQueryParameter("page", page.toString()) - addQueryParameter("size", size.toString()) - }) - - /** - * Make a request and parse the result as JSON. The response body is parsed by [reader]. The - * url to query is constructed using [urlBuilder]. In [urlBuilder], use - * [HttpUrl.Builder.addPathSegments] over [HttpUrl.Builder.encodedPath] to preserve the correct - * base URL. The request can optionally be modified with [requestBuilder], for example to POST - * content or add headers. - * @throws IOException if the request fails, has a unsuccessful status code or if the response - * cannot be read. - */ - inline fun request( - reader: ObjectReader, - urlBuilder: HttpUrl.Builder.() -> Unit, - requestBuilder: (Request.Builder.() -> Unit) = { } - ): T = request(urlBuilder, requestBuilder) { request, response -> - if (!response.isSuccessful) { - throw IOException("Request to ${request.url} failed (code ${response.code})") + ): List = request { + url("api/oauth-clients") + with(url.parameters) { + append("page", page.toString()) + append("size", size.toString()) } - val body = response.body ?: throw IOException("No response body to ${request.url}") - reader.readValue(body.byteStream()) } - /** - * Make a request without any response processing. The - * url to query is constructed using [urlBuilder]. In [urlBuilder], use - * [HttpUrl.Builder.addPathSegments] over [HttpUrl.Builder.encodedPath] to preserve the correct - * base URL. The request can optionally be modified with [requestBuilder], for example to POST - * content or add headers. - * @throws IOException if the request fails or has a unsuccessful status code. - */ - inline fun request( - urlBuilder: HttpUrl.Builder.() -> Unit, - requestBuilder: (Request.Builder.() -> Unit) = { }, - ) { - request(urlBuilder, requestBuilder) { request, response -> - if (!response.isSuccessful) { - throw IOException("Request to ${request.url} failed (code ${response.code})") + suspend inline fun request( + crossinline block: HttpRequestBuilder.() -> Unit, + ): T = withContext(Dispatchers.IO) { + with(httpClient.request(block)) { + if (!status.isSuccess()) { + throw IOException("Request to ${request.url} failed (code $status)") } + body() } } - /** - * Make a request without custom response processing. The - * url to query is constructed using [urlBuilder]. In [urlBuilder], use - * [HttpUrl.Builder.addPathSegments] over [HttpUrl.Builder.encodedPath] to preserve the correct - * base URL. The request can optionally be modified with [requestBuilder], for example to POST - * content or add headers. The response can be handled with [responseHandler], e.g., by - * evaluating the status code or the body with a custom body reader. - */ - inline fun request( - urlBuilder: HttpUrl.Builder.() -> Unit, - requestBuilder: Request.Builder.() -> Unit = { }, - responseHandler: (Request, Response) -> T, - ): T { - val request = Request.Builder().apply { - url(baseUrl.newBuilder().apply { - urlBuilder() - }.build()) - header("Authorization", "Bearer ${validToken.accessToken}") - requestBuilder() - }.build() - - return httpClient.newCall(request).execute().use { - responseHandler(request, it) - } + fun config(config: Config.() -> Unit): MPClient { + val oldConfig = toConfig() + val newConfig = toConfig().apply(config) + return if (oldConfig != newConfig) MPClient(newConfig) else this + } + + private fun toConfig(): Config = Config().apply { + httpClient = this@MPClient.originalHttpClient + url = this@MPClient.url + auth = this@MPClient.auth } - data class MPServerConfig( - val url: String, - val clientId: String, - val clientSecret: String, - ) { - val httpUrl: HttpUrl - get() = (url.trimEnd('/') + '/') - .toHttpUrlOrNull() - ?: throw MalformedURLException("Cannot parse base URL $url as an URL") + class Config { + internal var auth: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit = {} + + /** HTTP client to make requests with. */ + var httpClient: HttpClient? = null + + var url: String? = null + + fun auth(install: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit) { + auth = install + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Config + + return httpClient == other.httpClient + && url == other.url + && auth == other.auth + } + + override fun hashCode(): Int = Objects.hash(httpClient, url) } } diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuth2AccessToken.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuth2AccessToken.kt deleted file mode 100644 index e8c3698d2..000000000 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuth2AccessToken.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2021. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.management.client - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty -import java.time.Duration -import java.time.Instant - -@JsonIgnoreProperties(ignoreUnknown = true) -data class MPOAuth2AccessToken( - @JsonProperty("access_token") val accessToken: String, - @JsonProperty("refresh_token") val refreshToken: String? = null, - @JsonProperty("expires_in") val expiresIn: Long = 0, - @JsonProperty("token_type") val tokenType: String? = null, - @JsonProperty("user_id") val externalUserId: String? = null, -) { - private val expiration: Instant = Instant.now() + Duration.ofSeconds(expiresIn) - Duration.ofMinutes(5) - - fun isValid() = Instant.now() < expiration -} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt index edd80887d..17a1c338a 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt @@ -1,9 +1,12 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class MPOAuthClient( - @JsonProperty("clientId") val id: String, + @SerialName("clientId") val id: String, var clientSecret: String? = null, val scope: List = listOf(), val resourceIds: List = listOf(), diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOrganization.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOrganization.kt index 8adadce28..0c708b37c 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOrganization.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOrganization.kt @@ -1,11 +1,13 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** ManagementPortal Project DTO. */ +@Serializable data class MPOrganization( /** Organization id, a name that identifies it uniquely. */ - @JsonProperty("name") val id: String, + @SerialName("name") val id: String, /** Where a project is organized. */ val location: String? = null, /** Project description. */ diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt index 064891568..df2303934 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt @@ -1,14 +1,15 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonProperty -import java.time.ZonedDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** ManagementPortal Project DTO. */ +@Serializable data class MPProject( /** Project id, a name that identifies it uniquely. */ - @JsonProperty("projectName") val id: String, + @SerialName("projectName") val id: String, /** Project name, to be shown to users. */ - @JsonProperty("humanReadableProjectName") val name: String? = null, + @SerialName("humanReadableProjectName") val name: String? = null, /** Where a project is organized. */ val location: String? = null, /** Organization that organizes the project. */ @@ -18,7 +19,9 @@ data class MPProject( /** Any other attributes. */ val attributes: Map = emptyMap(), val projectStatus: String? = null, - val startDate: ZonedDateTime? = null, - val endDate: ZonedDateTime? = null, + /** ZonedDateTime */ + val startDate: String? = null, + /** ZonedDateTime */ + val endDate: String? = null, val sourceTypes: List = listOf(), ) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPRole.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPRole.kt index bef8e9043..ba792783a 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPRole.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPRole.kt @@ -9,10 +9,12 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class MPRole( - @JsonProperty("projectName") + @SerialName("projectName") val projectId: String? = null, val authorityName: String ) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceData.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceData.kt index ded309316..668ccc8ab 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceData.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceData.kt @@ -9,6 +9,9 @@ package org.radarbase.management.client +import kotlinx.serialization.Serializable + +@Serializable data class MPSourceData( val id: Long, //Source data type. diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceType.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceType.kt index c01131263..91362c600 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceType.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSourceType.kt @@ -9,6 +9,9 @@ package org.radarbase.management.client +import kotlinx.serialization.Serializable + +@Serializable data class MPSourceType( var id: Long, val producer: String, diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt index 8636fb0ee..aa54436bd 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt @@ -1,14 +1,15 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable /** ManagementPortal Subject DTO. */ +@Serializable data class MPSubject( /** User id, a name that identifies it uniquely. */ - @JsonProperty("login") val id: String?, + @SerialName("login") val id: String?, /** Project id that the subject belongs to. */ - @JsonIgnore val projectId: String? = null, + @kotlinx.serialization.Transient val projectId: String? = null, /** Full project details that a subject belongs to. */ val project: MPProject? = null, /** ID in an external system for the user. */ diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPUser.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPUser.kt index be4519b38..9d2f11d03 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPUser.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPUser.kt @@ -9,11 +9,12 @@ package org.radarbase.management.client -import com.fasterxml.jackson.annotation.JsonProperty -import java.time.ZonedDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class MPUser( - @JsonProperty("login") + @SerialName("login") val id: String, val firstName: String? = null, @@ -24,9 +25,11 @@ data class MPUser( val langKey: String? = null, val createdBy: String? = null, - val createdDate: ZonedDateTime? = null, + /** ZonedDateTime. */ + val createdDate: String? = null, val lastModifiedBy: String? = null, - val lastModifiedDate: ZonedDateTime? = null, + /** ZonedDateTime. */ + val lastModifiedDate: String? = null, val roles: List = listOf(), val authorities: List = listOf(), ) diff --git a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt index e16677649..84fead7f3 100644 --- a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt +++ b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt @@ -11,6 +11,9 @@ package org.radarbase.management.client import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.apache.http.entity.ContentType import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -18,19 +21,27 @@ import org.hamcrest.Matchers.hasSize import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.radarbase.management.auth.ClientCredentialsConfig +import org.radarbase.management.auth.clientCredentials +import org.slf4j.LoggerFactory import java.net.HttpURLConnection.HTTP_OK -import java.time.ZoneId -import java.time.ZonedDateTime +import java.net.HttpURLConnection.HTTP_UNAUTHORIZED +@OptIn(ExperimentalCoroutinesApi::class) class MPClientTest { private lateinit var wireMockServer: WireMockServer private lateinit var client: MPClient @BeforeEach fun setUp() { - wireMockServer = WireMockServer(9090); + wireMockServer = WireMockServer(9090) wireMockServer.start() + wireMockServer.stubFor( + get(anyUrl()) + .willReturn(aResponse() + .withStatus(HTTP_UNAUTHORIZED))) + wireMockServer.stubFor( post(urlEqualTo("/oauth/token")) .willReturn(aResponse() @@ -38,13 +49,23 @@ class MPClientTest { .withHeader("content-type", ContentType.APPLICATION_JSON.toString()) .withBody("{\"access_token\":\"abcdef\"}"))) - client = MPClient( - MPClient.MPServerConfig( - "http://localhost:9090/", - "test", - "test", - ), - ) + client = mpClient { + url = "http://localhost:9090/" + auth { emit -> + clientCredentials( + authConfig = ClientCredentialsConfig( + tokenUrl = "http://localhost:9090/oauth/token", + clientId = "test", + clientSecret = "test", + ), + targetHost = "localhost", + emit = { + logger.info("Got new token: {}", it) + emit(it) + } + ) + } + } } @AfterEach @@ -53,7 +74,7 @@ class MPClientTest { } @Test - fun testClients() { + fun testClients() = runBlocking { val body = """ [{ @@ -120,11 +141,11 @@ class MPClientTest { ))) wireMockServer.verify(1, postRequestedFor(urlEqualTo("/oauth/token"))) - wireMockServer.verify(1, getRequestedFor(urlPathEqualTo("/api/oauth-clients"))) + wireMockServer.verify(2, getRequestedFor(urlPathEqualTo("/api/oauth-clients"))) } @Test - fun testProjects() { + fun testProjects() = runTest { val body = """ [{ @@ -186,9 +207,9 @@ class MPClientTest { description = "d2", organization = MPOrganization(id = "Mixed"), location = "here", - startDate = ZonedDateTime.of(2021, 6, 7, 2, 2, 0, 0, ZoneId.of("UTC")), + startDate = "2021-06-07T02:02:00Z", projectStatus = "ONGOING", - endDate = ZonedDateTime.of(2022, 6, 7, 2, 2, 0, 0, ZoneId.of("UTC")), + endDate = "2022-06-07T02:02:00Z", attributes = mapOf( "External-project-id" to "p2a", "Human-readable-project-name" to "P2", @@ -197,6 +218,10 @@ class MPClientTest { ))) wireMockServer.verify(1, postRequestedFor(urlEqualTo("/oauth/token"))) - wireMockServer.verify(1, getRequestedFor(urlPathEqualTo("/api/projects"))) + wireMockServer.verify(2, getRequestedFor(urlPathEqualTo("/api/projects"))) + } + + companion object { + private val logger = LoggerFactory.getLogger(MPClientTest::class.java) } } From 6be785fe6356db08093134f5aab764cc867a8c87 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 5 Dec 2022 14:38:09 +0100 Subject: [PATCH 002/120] Update to the latest Kotlin version --- managementportal-client/build.gradle | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index d92ed2f71..936fb6a92 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -23,7 +23,6 @@ description = "Kotlin ManagementPortal client" dependencies { api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22") implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.22") - api(project(":oauth-client-util")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) api(platform("io.ktor:ktor-bom:$ktor_version")) @@ -46,8 +45,8 @@ dependencies { tasks.withType(KotlinCompile) { kotlinOptions { jvmTarget = "11" - apiVersion = "1.6" - languageVersion = "1.6" + apiVersion = "1.7" + languageVersion = "1.7" } } From 4f22bbee25bc4911caf9ff8d13b55088aa5c3f23 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 3 Jan 2023 13:49:10 +0100 Subject: [PATCH 003/120] Small fix --- .../main/kotlin/org/radarbase/management/client/MPClient.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index 158a571ca..66d3b7c88 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -14,7 +14,6 @@ import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.auth.* -import io.ktor.client.plugins.auth.providers.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.statement.* @@ -37,7 +36,7 @@ fun mpClient(config: MPClient.Config.() -> Unit): MPClient { /** * Client for the ManagementPortal REST API. */ -@Suppress("unused") +@Suppress("unused", "MemberVisibilityCanBePrivate") class MPClient(config: Config) { private val _token: MutableStateFlow = MutableStateFlow(null) From dcb7b1af105de168b3465d2ea22ff88b378c98c8 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 8 Feb 2023 10:45:13 +0100 Subject: [PATCH 004/120] Update Gradle and fix MP client client credentials --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 12 +++-- gradlew.bat | 1 + managementportal-client/build.gradle | 12 ++--- .../management/auth/OAuthClientProvider.kt | 31 ++++++++++--- .../radarbase/management/client/MPClient.kt | 12 +++-- .../management/client/MPClientTest.kt | 41 ++++++++++++++---- 9 files changed, 79 insertions(+), 35 deletions(-) diff --git a/build.gradle b/build.gradle index fcaea71d0..e68e6204d 100644 --- a/build.gradle +++ b/build.gradle @@ -242,7 +242,7 @@ task cleanResources(type: Delete) { } wrapper { - gradleVersion '7.5.1' + gradleVersion '7.6' } task stage(dependsOn: 'bootWar') { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 36524 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%0DGK?7GHTj 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 VwlMN}`%er%aGR6olj~j${vQ;P=LY}) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661ee..f398c33c4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6c..65dcd68d6 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index f127cfd49..93e3f59f1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index 936fb6a92..e2ad7930e 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -9,8 +9,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * See the file LICENSE in the root of this repository. */ plugins { - id 'org.jetbrains.kotlin.jvm' version "1.7.22" - id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.22' + id 'org.jetbrains.kotlin.jvm' version "1.8.10" + id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' id 'org.jetbrains.dokka' version "1.7.20" id 'maven-publish' } @@ -21,13 +21,13 @@ targetCompatibility = JavaVersion.VERSION_11 description = "Kotlin ManagementPortal client" dependencies { - api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.22") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.22") + api("org.jetbrains.kotlin:kotlin-stdlib:1.8.10") + api(platform('io.ktor:ktor-bom:2.2.3')) + implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.10") api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) - api(platform("io.ktor:ktor-bom:$ktor_version")) api("io.ktor:ktor-client-core") - implementation("io.ktor:ktor-client-auth") + api("io.ktor:ktor-client-auth") implementation("io.ktor:ktor-client-cio") implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-serialization-kotlinx-json") diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt index d8c050803..f3251b434 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt @@ -12,6 +12,13 @@ import io.ktor.http.* import io.ktor.http.auth.* import io.ktor.serialization.kotlinx.json.* import io.ktor.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger(Auth::class.java) /** * Installs the client's [BearerAuthProvider]. @@ -25,10 +32,10 @@ fun Auth.clientCredentials(block: ClientCredentialsAuthConfig.() -> Unit) { fun Auth.clientCredentials( authConfig: ClientCredentialsConfig, targetHost: String? = null, - emit: suspend (MPOAuth2AccessToken?) -> Unit = {}, -) { +): Flow { requireNotNull(authConfig.clientId) { "Missing client ID" } requireNotNull(authConfig.clientSecret) { "Missing client secret"} + val flow = MutableStateFlow(null) clientCredentials { if (targetHost != null) { @@ -37,19 +44,29 @@ fun Auth.clientCredentials( } } requestToken { - val refreshTokenInfo: MPOAuth2AccessToken = client.submitForm { - url(authConfig.tokenUrl) - formData { + val response = client.submitForm( + url = authConfig.tokenUrl, + formParameters = Parameters.build { append("grant_type", "client_credentials") append("client_id", authConfig.clientId) append("client_secret", authConfig.clientSecret) } + ) { + accept(ContentType.Application.Json) markAsRequestTokenRequest() - }.body() - emit(refreshTokenInfo) + } + val refreshTokenInfo: MPOAuth2AccessToken? = if (!response.status.isSuccess()) { + logger.error("Failed to fetch new token: {}", response.bodyAsText()) + null + } else { + response.body() + } + flow.value = refreshTokenInfo refreshTokenInfo } } + + return flow } /** diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index 66d3b7c88..9dd0573f4 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -38,9 +38,7 @@ fun mpClient(config: MPClient.Config.() -> Unit): MPClient { */ @Suppress("unused", "MemberVisibilityCanBePrivate") class MPClient(config: Config) { - private val _token: MutableStateFlow = MutableStateFlow(null) - - val token: Flow = _token + lateinit var token: Flow private val url: String = requireNotNull(config.url) { "Missing server URL" @@ -48,7 +46,7 @@ class MPClient(config: Config) { /** HTTP client to make requests with. */ private val originalHttpClient: HttpClient? = config.httpClient - private val auth: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit = config.auth + private val auth: Auth.() -> Flow = config.auth val httpClient = (originalHttpClient ?: HttpClient(CIO)).config { install(HttpTimeout) { @@ -63,7 +61,7 @@ class MPClient(config: Config) { }) } install(Auth) { - auth(_token::emit) + token = auth() } defaultRequest { url(this@MPClient.url) @@ -149,14 +147,14 @@ class MPClient(config: Config) { } class Config { - internal var auth: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit = {} + internal var auth: Auth.() -> Flow = { MutableStateFlow(null) } /** HTTP client to make requests with. */ var httpClient: HttpClient? = null var url: String? = null - fun auth(install: Auth.(suspend (MPOAuth2AccessToken?) -> Unit) -> Unit) { + fun auth(install: Auth.() -> Flow) { auth = install } diff --git a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt index 84fead7f3..a306831ab 100644 --- a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt +++ b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt @@ -9,11 +9,17 @@ package org.radarbase.management.client +import com.fasterxml.jackson.databind.deser.DataFormatReaders.Match import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.matching.ContentPattern +import com.github.tomakehurst.wiremock.matching.EqualToPattern +import com.github.tomakehurst.wiremock.stubbing.StubMapping import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.apache.http.entity.ContentType import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -22,6 +28,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.radarbase.management.auth.ClientCredentialsConfig +import org.radarbase.management.auth.MPOAuth2AccessToken import org.radarbase.management.auth.clientCredentials import org.slf4j.LoggerFactory import java.net.HttpURLConnection.HTTP_OK @@ -29,6 +36,7 @@ import java.net.HttpURLConnection.HTTP_UNAUTHORIZED @OptIn(ExperimentalCoroutinesApi::class) class MPClientTest { + private lateinit var authStub: StubMapping private lateinit var wireMockServer: WireMockServer private lateinit var client: MPClient @@ -42,7 +50,7 @@ class MPClientTest { .willReturn(aResponse() .withStatus(HTTP_UNAUTHORIZED))) - wireMockServer.stubFor( + authStub = wireMockServer.stubFor( post(urlEqualTo("/oauth/token")) .willReturn(aResponse() .withStatus(HTTP_OK) @@ -51,18 +59,14 @@ class MPClientTest { client = mpClient { url = "http://localhost:9090/" - auth { emit -> + auth { clientCredentials( authConfig = ClientCredentialsConfig( tokenUrl = "http://localhost:9090/oauth/token", - clientId = "test", - clientSecret = "test", + clientId = "testId", + clientSecret = "testSecret", ), targetHost = "localhost", - emit = { - logger.info("Got new token: {}", it) - emit(it) - } ) } } @@ -112,6 +116,7 @@ class MPClientTest { .withBody(body))) val clients = client.requestClients() + assertThat(clients, hasSize(2)) assertThat(clients, Matchers.equalTo(listOf( MPOAuthClient( @@ -140,10 +145,28 @@ class MPClientTest { ) ))) - wireMockServer.verify(1, postRequestedFor(urlEqualTo("/oauth/token"))) + wireMockServer.verify(1, postRequestedFor(urlEqualTo("/oauth/token")) + .withRequestBody(EqualToPattern("grant_type=client_credentials&client_id=testId&client_secret=testSecret"))) wireMockServer.verify(2, getRequestedFor(urlPathEqualTo("/api/oauth-clients"))) } + @Test + fun testParseToken() { + val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + val token = json.decodeFromString("""{"access_token":"access token","token_type":"bearer","expires_in":899,"scope":"PROJECT.READ","iss":"ManagementPortal","grant_type":"client_credentials","iat":1600000000,"jti":"some token"}""") + assertThat(token, Matchers.equalTo( + MPOAuth2AccessToken( + accessToken = "access token", + expiresIn = 899, + tokenType = "bearer", + ) + )) + } + @Test fun testProjects() = runTest { val body = From 221c053de68ae94c095373a9f4594ff9be21e30f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 8 Feb 2023 12:47:05 +0100 Subject: [PATCH 005/120] Check authorization via an AuthorizationOracle Refactorings include: - Convert radar-auth to Kotlin - Allow tokens other than JWTs to be verified - Move all permission checks out of RadarToken - Allow the AuthorizationOracle to check organization <-> project links - Remove configuration parsing from radar-auth - Use ktor client to fetch public keys - Use coroutines to verify keys --- build.gradle | 15 + gradle/artifacts.gradle | 9 +- managementportal-client/build.gradle | 21 +- radar-auth/bin/radar-is.yml | 2 - radar-auth/build.gradle | 38 +- .../auth/authentication/AlgorithmLoader.java | 82 ---- .../StaticTokenVerifierLoader.kt | 8 + .../auth/authentication/TokenValidator.java | 300 --------------- .../auth/authentication/TokenValidator.kt | 156 ++++++++ .../auth/authentication/TokenVerifier.kt | 16 + .../authentication/TokenVerifierLoader.kt | 11 + .../authorization/AuthorityReferenceSet.kt | 22 ++ .../auth/authorization/AuthorizationOracle.kt | 359 ++++++++++++++++++ .../auth/authorization/EntityDetails.kt | 62 +++ .../authorization/EntityRelationService.kt | 11 + .../{Permission.java => Permission.kt} | 130 ++----- .../auth/authorization/Permissions.java | 172 --------- .../authorization/RadarAuthorization.java | 205 ---------- .../auth/authorization/RoleAuthority.java | 84 ---- .../auth/authorization/RoleAuthority.kt | 68 ++++ .../auth/config/TokenValidatorConfig.java | 21 - .../config/TokenVerifierPublicKeyConfig.java | 111 ------ .../exception/ConfigurationException.java | 18 - .../exception/InvalidPublicKeyException.kt | 7 + .../exception/NotAuthorizedException.java | 24 -- .../auth/exception/NotAuthorizedException.kt | 13 + .../exception/TokenValidationException.java | 18 - .../exception/TokenValidationException.kt | 10 + .../auth/jwks/ECPEMCertificateParser.kt | 18 + .../org/radarbase/auth/jwks/JsonWebKey.kt | 112 ++++++ .../org/radarbase/auth/jwks/JsonWebKeySet.kt | 8 + .../radarbase/auth/jwks/JwkAlgorithmParser.kt | 92 +++++ .../java/org/radarbase/auth/jwks/JwkParser.kt | 8 + .../auth/jwks/JwksTokenVerifierLoader.kt | 95 +++++ .../auth/jwks/PEMCertificateParser.kt | 57 +++ .../auth/jwks/RSAPEMCertificateParser.kt | 17 + .../org/radarbase/auth/jwt/JwtRadarToken.kt | 98 +++++ .../radarbase/auth/jwt/JwtTokenVerifier.kt | 35 ++ .../auth/security/jwk/JavaWebKey.java | 63 --- .../auth/security/jwk/JavaWebKeySet.java | 20 - .../auth/token/AbstractRadarToken.java | 221 ----------- .../auth/token/AbstractRadarToken.kt | 55 +++ .../auth/token/AuthorityReference.java | 86 ----- .../auth/token/AuthorityReference.kt | 39 ++ .../radarbase/auth/token/JwtRadarToken.java | 190 --------- .../org/radarbase/auth/token/RadarToken.java | 252 ------------ .../org/radarbase/auth/token/RadarToken.kt | 106 ++++++ .../AbstractTokenValidationAlgorithm.java | 36 -- .../ECTokenValidationAlgorithm.java | 28 -- .../RSATokenValidationAlgorithm.java | 27 -- .../validation/TokenValidationAlgorithm.java | 26 -- .../org/radarbase/auth/util/CachedValue.kt | 122 ++++++ .../org/radarbase/auth/util/Extensions.kt | 24 ++ .../authentication/TokenValidatorTest.java | 59 ++- .../authorization/RadarAuthorizationTest.java | 111 +++--- .../TokenVerifierPublicKeyConfigTest.java | 46 --- .../auth/security/jwk/JsonWebKeyTest.kt | 24 ++ .../auth/token/AbstractRadarTokenTest.java | 151 +++++--- .../radarbase/auth/util/TokenTestUtils.java | 7 +- radar-auth/src/test/resources/radar-is-2.yml | 1 - radar-auth/src/test/resources/radar-is.yml | 4 - .../ManagementPortalSecurityConfigLoader.java | 24 +- .../config/audit/CustomRevisionListener.java | 2 +- .../management/domain/Authority.java | 4 +- .../radarbase/management/domain/Group.java | 2 +- .../management/domain/MetaToken.java | 2 +- .../management/domain/Organization.java | 2 +- .../radarbase/management/domain/Project.java | 2 +- .../radarbase/management/domain/Source.java | 2 +- .../management/domain/SourceData.java | 2 +- .../management/domain/SourceType.java | 2 +- .../radarbase/management/domain/Subject.java | 2 +- .../org/radarbase/management/domain/User.java | 2 +- .../support/AbstractEntityListener.java | 2 +- .../CustomAuditEventRepository.java | 2 +- .../repository/OrganizationRepository.java | 4 +- .../repository/ProjectRepository.java | 6 +- .../repository/filters/UserFilter.java | 8 +- .../security/ClaimsTokenEnhancer.java | 6 +- .../management/security}/Constants.java | 2 +- .../security/JwtAuthenticationFilter.java | 4 +- .../security/RadarAuthentication.java | 5 +- .../security/SessionRadarToken.java | 34 +- ...nagementPortalJwtAccessTokenConverter.java | 3 +- .../ManagementPortalOauthKeyStoreHandler.java | 42 +- .../algorithm/AsymmetricalJwtAlgorithm.java | 13 +- .../security/jwt/algorithm/JwtAlgorithm.java | 6 +- .../management/service/AuthService.kt | 85 +++++ .../service/OrganizationService.java | 52 ++- .../management/service/ProjectService.java | 21 +- .../management/service/RoleService.java | 24 +- .../management/service/SourceService.java | 48 +-- .../management/service/SubjectService.java | 7 +- .../management/service/UserService.java | 55 +-- .../management/service/dto/AuthorityDTO.java | 4 +- .../management/service/dto/UserDTO.java | 2 +- .../management/web/rest/AccountResource.java | 29 +- .../management/web/rest/AuditResource.java | 14 +- .../web/rest/AuthorityResource.java | 23 +- .../management/web/rest/GroupResource.java | 32 +- .../web/rest/MetaTokenResource.java | 13 +- .../web/rest/OAuthClientsResource.java | 54 +-- .../web/rest/OrganizationResource.java | 20 +- .../management/web/rest/ProjectResource.java | 79 ++-- .../management/web/rest/RoleResource.java | 25 +- .../web/rest/SourceDataResource.java | 22 +- .../management/web/rest/SourceResource.java | 59 +-- .../web/rest/SourceTypeResource.java | 24 +- .../management/web/rest/SubjectResource.java | 139 +++---- .../management/web/rest/TokenKeyEndpoint.java | 4 +- .../management/web/rest/UserResource.java | 28 +- .../auth/authentication/OAuthHelper.java | 34 +- .../management/config/MockConfiguration.java | 16 +- .../JwtAuthenticationFilterIntTest.java | 21 +- .../service/MetaTokenServiceTest.java | 22 +- .../service/OAuthClientServiceTestUtil.java | 4 +- ...IntegrationWorkFlowOnServiceLevelTest.java | 13 +- .../service/SubjectServiceTest.java | 16 +- .../service/UserServiceIntTest.java | 2 +- .../web/rest/AccountResourceIntTest.java | 48 ++- .../web/rest/AuditResourceIntTest.java | 11 +- .../web/rest/GroupResourceIntTest.java | 18 +- .../web/rest/LogsResourceIntTest.java | 10 +- .../web/rest/OAuthClientsResourceIntTest.java | 38 +- .../web/rest/OrganizationResourceIntTest.java | 9 +- .../web/rest/ProfileInfoResourceIntTest.java | 10 +- .../web/rest/ProjectResourceIntTest.java | 48 +-- .../web/rest/SourceDataResourceIntTest.java | 9 +- .../web/rest/SourceResourceIntTest.java | 45 ++- .../web/rest/SourceTypeResourceIntTest.java | 44 +-- .../web/rest/SubjectResourceIntTest.java | 78 ++-- .../web/rest/UserResourceIntTest.java | 30 +- .../webapp/CheckTranslationsUnitTest.java | 8 +- src/test/resources/logback.xml | 1 + 134 files changed, 2746 insertions(+), 3068 deletions(-) delete mode 100644 radar-auth/bin/radar-is.yml delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/AlgorithmLoader.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/StaticTokenVerifierLoader.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifier.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifierLoader.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt rename radar-auth/src/main/java/org/radarbase/auth/authorization/{Permission.java => Permission.kt} (50%) delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/Permissions.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/RadarAuthorization.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/config/TokenValidatorConfig.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfig.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/ConfigurationException.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/InvalidPublicKeyException.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKeySet.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/JwkParser.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/JwksTokenVerifierLoader.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/PEMCertificateParser.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKey.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKeySet.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/JwtRadarToken.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/validation/AbstractTokenValidationAlgorithm.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/validation/ECTokenValidationAlgorithm.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/validation/RSATokenValidationAlgorithm.java delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/validation/TokenValidationAlgorithm.java create mode 100644 radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt delete mode 100644 radar-auth/src/test/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfigTest.java create mode 100644 radar-auth/src/test/java/org/radarbase/auth/security/jwk/JsonWebKeyTest.kt delete mode 100644 radar-auth/src/test/resources/radar-is-2.yml delete mode 100644 radar-auth/src/test/resources/radar-is.yml rename {radar-auth/src/main/java/org/radarbase/auth/config => src/main/java/org/radarbase/management/security}/Constants.java (90%) create mode 100644 src/main/java/org/radarbase/management/service/AuthService.kt diff --git a/build.gradle b/build.gradle index e68e6204d..84a378ab8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,8 @@ import org.gradle.internal.os.OperatingSystem +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + import java.util.concurrent.TimeUnit buildscript { @@ -19,6 +23,9 @@ plugins { id 'de.undercouch.download' version '5.3.0' apply false id "io.github.gradle-nexus.publish-plugin" version "1.1.0" id("com.github.ben-manes.versions") version "0.43.0" + id 'org.jetbrains.kotlin.jvm' version "1.8.10" + id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' apply false + id 'org.jetbrains.dokka' version "1.7.20" } apply plugin: 'org.springframework.boot' @@ -120,6 +127,14 @@ if (OperatingSystem.current().isWindows()) { } } +tasks.withType(KotlinCompile).configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + apiVersion = KotlinVersion.KOTLIN_1_8 + languageVersion = KotlinVersion.KOTLIN_1_8 + } +} + test { jvmArgs = [ '--add-modules', 'java.se', diff --git a/gradle/artifacts.gradle b/gradle/artifacts.gradle index 876c1fecd..967de7104 100644 --- a/gradle/artifacts.gradle +++ b/gradle/artifacts.gradle @@ -9,23 +9,26 @@ jar { } // custom tasks for creating source/javadoc jars -task sourcesJar(type: Jar, dependsOn: classes) { +tasks.register('sourcesJar', Jar) { archiveClassifier.set('sources') from sourceSets.main.allSource manifest.from sharedManifest + dependsOn(classes) } if (!project.hasProperty("projectLanguage") || projectLanguage == "java") { - task javadocJar(type: Jar, dependsOn: javadoc) { + tasks.register('javadocJar', Jar) { archiveClassifier.set('javadoc') from javadoc.destinationDir manifest.from sharedManifest + dependsOn(javadoc) } } else if (projectLanguage == "kotlin") { - task javadocJar(type: Jar, dependsOn: dokkaJavadoc) { + tasks.register('javadocJar', Jar) { from("$buildDir/dokka/javadoc") archiveClassifier.set("javadoc") manifest.from sharedManifest + dependsOn(dokkaJavadoc) } } diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index e2ad7930e..ed6ed9385 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /* @@ -9,9 +11,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * See the file LICENSE in the root of this repository. */ plugins { - id 'org.jetbrains.kotlin.jvm' version "1.8.10" - id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' - id 'org.jetbrains.dokka' version "1.7.20" + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'org.jetbrains.dokka' id 'maven-publish' } @@ -42,17 +44,18 @@ dependencies { testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version } -tasks.withType(KotlinCompile) { - kotlinOptions { - jvmTarget = "11" - apiVersion = "1.7" - languageVersion = "1.7" +tasks.withType(KotlinCompile).configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + apiVersion = KotlinVersion.KOTLIN_1_8 + languageVersion = KotlinVersion.KOTLIN_1_8 } } -task ghPagesJavadoc(type: Copy, dependsOn: dokkaJavadoc) { +tasks.register('ghPagesJavadoc', Copy) { from file("$buildDir/dokka/javadoc") into file("$rootDir/public/managementportal-client-javadoc") + dependsOn(dokkaJavadoc) } test { diff --git a/radar-auth/bin/radar-is.yml b/radar-auth/bin/radar-is.yml deleted file mode 100644 index 4947233d1..000000000 --- a/radar-auth/bin/radar-is.yml +++ /dev/null @@ -1,2 +0,0 @@ -resourceName: unit_test -publicKeyEndpoint: http://localhost:8089/oauth/token_key diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index e19a4e760..e0c040032 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -1,4 +1,13 @@ -apply plugin: 'maven-publish' +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'maven-publish' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'org.jetbrains.dokka' +} sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -7,22 +16,32 @@ description = 'Library for authentication and authorization of JWT tokens issued dependencies { api group: 'com.auth0', name: 'java-jwt', version: oauth_jwt_version - api group: 'com.squareup.okhttp3', name: 'okhttp', version: okhttp_version + + implementation(platform('io.ktor:ktor-bom:2.2.3')) + implementation("io.ktor:ktor-client-core") + implementation("io.ktor:ktor-client-cio") + implementation("io.ktor:ktor-client-content-negotiation") + implementation("io.ktor:ktor-serialization-kotlinx-json") implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - implementation(platform("com.fasterxml.jackson:jackson-bom:$jackson_version")) - implementation group: 'com.fasterxml.jackson.core' , name: 'jackson-databind' - implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml' testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' - testImplementation group: 'com.github.stefanbirkner', name: 'system-rules', version: '1.19.0' + testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: logback_version testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version } +tasks.withType(KotlinCompile).configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + apiVersion = KotlinVersion.KOTLIN_1_8 + languageVersion = KotlinVersion.KOTLIN_1_8 + } +} + test { testLogging { exceptionFormat = 'full' @@ -30,12 +49,13 @@ test { useJUnitPlatform() } -task ghPagesJavadoc(type: Copy, dependsOn: javadoc) { - from javadoc.destinationDir +tasks.register('ghPagesJavadoc', Copy) { + from file("$buildDir/dokka/javadoc") into file("$rootDir/public/radar-auth-javadoc") + dependsOn(dokkaJavadoc) } -ext.projectLanguage = "java" +ext.projectLanguage = "kotlin" apply from: "$rootDir/gradle/style.gradle" apply from: "$rootDir/gradle/publishing.gradle" diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/AlgorithmLoader.java b/radar-auth/src/main/java/org/radarbase/auth/authentication/AlgorithmLoader.java deleted file mode 100644 index a6de57b68..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/authentication/AlgorithmLoader.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.radarbase.auth.authentication; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import org.radarbase.auth.exception.TokenValidationException; -import org.radarbase.auth.security.jwk.JavaWebKey; -import org.radarbase.auth.security.jwk.JavaWebKeySet; -import org.radarbase.auth.token.validation.ECTokenValidationAlgorithm; -import org.radarbase.auth.token.validation.RSATokenValidationAlgorithm; -import org.radarbase.auth.token.validation.TokenValidationAlgorithm; - -public class AlgorithmLoader { - - private final List supportedAlgorithmsForWebKeySets; - - /** - * Creates an instance of {@link AlgorithmLoader} with lists of - * {@link TokenValidationAlgorithm} provided. - * @param supportedAlgorithmsForWebKeySets default support. Algorithms to be supported for - * public keys shared from public key endpoints as - * {@link JavaWebKeySet}. - */ - public AlgorithmLoader( - List supportedAlgorithmsForWebKeySets) { - this.supportedAlgorithmsForWebKeySets = supportedAlgorithmsForWebKeySets; - } - - private Algorithm algorithmFromPublicKey(String publicKey) { - // We deny to trust the public key if the reported algorithm is unknown to us - // https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ - return loadAlgorithmFromPublicKey(supportedAlgorithmsForWebKeySets, publicKey); - } - - private static Algorithm loadAlgorithmFromPublicKey( - List supportedAlgorithms, String publicKey) { - return supportedAlgorithms - .stream() - .filter(algorithm -> publicKey.startsWith(algorithm.getKeyHeader())) - .findFirst() - .orElseThrow(() -> - new TokenValidationException("Unsupported public key: " + publicKey)) - .getAlgorithm(publicKey); - } - - /** - * Loads Algorithms from {@link JavaWebKeySet}. - * @param publicKeyInfo java web key set to load algorithm. - * @return List of {@link Algorithm}. - */ - public List loadAlgorithmsFromJavaWebKeys(JavaWebKeySet publicKeyInfo) { - return publicKeyInfo - .getKeys() - .stream() - .map(JavaWebKey::getValue) - .map(this::algorithmFromPublicKey) - .collect(Collectors.toList()); - } - - /** - * Creates in {@link JWTVerifier} using Algorithms and audience. - * @param algorithm instance of {@link Algorithm} to create verifier from. - * @param audience to which audience. - * @return instance of {@link JWTVerifier}. - */ - public static JWTVerifier buildVerifier(Algorithm algorithm, String audience) { - return JWT.require(algorithm) - .withAudience(audience) - .build(); - } - - /** Load the default algorithms, ECDSA and RSA. */ - public static List defaultAlgorithms() { - return Arrays.asList( - new ECTokenValidationAlgorithm(), - new RSATokenValidationAlgorithm()); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/StaticTokenVerifierLoader.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/StaticTokenVerifierLoader.kt new file mode 100644 index 000000000..f07c5522f --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/StaticTokenVerifierLoader.kt @@ -0,0 +1,8 @@ +package org.radarbase.auth.authentication + +/** Load token verifiers as a static object. */ +class StaticTokenVerifierLoader( + private val tokenVerifiers: List +) : TokenVerifierLoader { + override suspend fun fetch(): List = tokenVerifiers +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.java b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.java deleted file mode 100644 index fe2a5e6b6..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.java +++ /dev/null @@ -1,300 +0,0 @@ -package org.radarbase.auth.authentication; - -import java.net.URI; -import java.time.Duration; -import java.time.Instant; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.exceptions.SignatureVerificationException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.fasterxml.jackson.databind.ObjectMapper; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.radarbase.auth.config.TokenValidatorConfig; -import org.radarbase.auth.config.TokenVerifierPublicKeyConfig; -import org.radarbase.auth.exception.TokenValidationException; -import org.radarbase.auth.security.jwk.JavaWebKeySet; -import org.radarbase.auth.token.JwtRadarToken; -import org.radarbase.auth.token.RadarToken; -import org.radarbase.auth.token.validation.TokenValidationAlgorithm; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Validates JWT token signed by the Management Portal. It is synchronized and may be used from - * multiple threads. If the status of the public key should be checked immediately, call - * {@link #refresh()} directly after creating this validator. It currently does not check this, so - * that the validator can be used even if a remote ManagementPortal is not reachable during - * construction. - */ -public class TokenValidator { - private static final Logger LOGGER = LoggerFactory.getLogger(TokenValidator.class); - private static final Duration FETCH_TIMEOUT_DEFAULT = Duration.ofMinutes(1); - - private final TokenValidatorConfig config; - - // If a client presents a token with an invalid signature, it might be the keypair was changed. - // In that case we need to fetch it again, but we don't want a malicious client to be able to - // make us DOS our own identity server. Fetching it at maximum once per minute mitigates this. - private final Duration fetchTimeout; - - private final OkHttpClient client; - private final ObjectMapper mapper; - private final AlgorithmLoader algorithmLoader; - - private List verifiers; - private Instant lastFetch = Instant.MIN; - - private TokenValidator(Builder builder) { - this.mapper = builder.mapper; - this.fetchTimeout = builder.fetchTimeout; - this.client = builder.httpClient; - this.config = builder.config; - this.algorithmLoader = new AlgorithmLoader(builder.algorithms); - this.verifiers = builder.verifiers; - } - - /** - * Default constructor. Will load the identity server configuration from a file called - * radar-is.yml that should be on the classpath, or its location defined in the - * RADAR_IS_CONFIG_LOCATION environment variable. Will also fetch the public key from the - * identity server for checking token signatures. - */ - public TokenValidator() { - this(new Builder().verify()); - } - - /** - * Constructor where TokenValidatorConfig can be passed instead of it being loaded from file. - * - * @param config The identity server configuration - */ - public TokenValidator(TokenValidatorConfig config) { - this(new Builder().config(config).verify()); - } - - /** - * Validates an access token and returns the decoded JWT as a {@link DecodedJWT} object. - *

- * If we have not yet fetched the JWT public key, this method will fetch it. If a signature can - * not be verified, this method will fetch the JWT public key again, as it might have been - * changed, and re-check the token. However this fetching of the public key will only be - * performed at most once every fetchTimeout seconds, to prevent (malicious) - * clients from making us call the token endpoint too frequently. - *

- * - * @param token The access token - * @return The decoded access token - * @throws TokenValidationException If the token can not be validated. - */ - public RadarToken validateAccessToken(String token) throws TokenValidationException { - return validateAccessToken(token, true); - } - - private RadarToken validateAccessToken(String token, boolean tryRefresh) { - List localVerifiers = getVerifiers(); - boolean signatureFailed = false; - for (JWTVerifier verifier : localVerifiers) { - try { - DecodedJWT jwt = verifier.verify(token); - Map claims = jwt.getClaims(); - - // Do not print full token with signature to avoid exposing valid token in logs. - LOGGER.debug("Verified JWT header {} and payload {}", - jwt.getHeader(), jwt.getPayload()); - - // check for scope claim - if (!claims.containsKey(JwtRadarToken.SCOPE_CLAIM)) { - throw new TokenValidationException("The required claim " - + JwtRadarToken.SCOPE_CLAIM + "is missing from the token"); - } - return new JwtRadarToken(jwt); - } catch (SignatureVerificationException sve) { - LOGGER.debug("Client presented a token with an incorrect signature."); - signatureFailed = true; - } catch (JWTVerificationException ex) { - LOGGER.debug("Verifier {} with implementation {} did not accept token", - verifier, verifier.getClass()); - } - } - if (signatureFailed && tryRefresh) { - LOGGER.info("Trying to fetch public keys again..."); - try { - refresh(); - } catch (TokenValidationException ex) { - // Log and Continue with validation - LOGGER.warn("Could not fetch public keys.", ex); - } - return validateAccessToken(token, false); - } else { - throw new TokenValidationException( - "No registered validator could authenticate this token"); - } - } - - private List getVerifiers() { - synchronized (this) { - if (!verifiers.isEmpty()) { - return verifiers; - } - } - - List localVerifiers = loadVerifiers(); - - synchronized (this) { - verifiers = localVerifiers; - return verifiers; - } - } - - /** - * Refreshes the token verifier public key. - * @throws TokenValidationException if the public key could not be refreshed. - */ - public void refresh() throws TokenValidationException { - List localVerifiers = loadVerifiers(); - if (!localVerifiers.isEmpty()) { - synchronized (this) { - this.verifiers = localVerifiers; - } - } - } - - private List loadVerifiers() throws TokenValidationException { - synchronized (this) { - // whether successful or not, do not request the key more than once per minute - if (Instant.now().isBefore(lastFetch.plus(fetchTimeout))) { - // it hasn't been long enough ago to fetch the key again, we deny access - LOGGER.warn("Fetched public key less than {} ago, denied access.", fetchTimeout); - throw new TokenValidationException("Not fetching public key more than once every " - + fetchTimeout); - } - lastFetch = Instant.now(); - } - - return streamEmptyIfNull(config.getPublicKeyEndpoints()) - .map(this::algorithmFromServerPublicKeyEndpoint) - .flatMap(List::stream) - .map(alg -> AlgorithmLoader.buildVerifier(alg, config.getResourceName())) - .collect(Collectors.toList()); - } - - private List algorithmFromServerPublicKeyEndpoint(URI serverUri) throws - TokenValidationException { - LOGGER.info("Getting the JWT public key at " + serverUri); - try { - Request request = new Request.Builder() - .url(serverUri.toURL()) - .header("Accept", "application/json") - .build(); - try (Response response = client.newCall(request).execute()) { - if (response.isSuccessful() && response.body() != null) { - JavaWebKeySet publicKeyInfo = mapper.readValue(response.body().string(), - JavaWebKeySet.class); - LOGGER.debug("Processing {} public keys from public-key endpoint {}", - publicKeyInfo.getKeys().size(), serverUri.toURL()); - return algorithmLoader.loadAlgorithmsFromJavaWebKeys(publicKeyInfo); - } else { - throw new TokenValidationException("Invalid token signature. Could not load " - + "newer public keys"); - } - } - } catch (Exception ex) { - throw new TokenValidationException(ex); - } - } - - private static Stream streamEmptyIfNull(Collection collection) { - return collection != null ? collection.stream() : Stream.empty(); - } - - /** Builder for the TokenValidator. Prefer this {@link #build()} method over the constructor - * invocations. */ - public static class Builder { - private static final int DEFAULT_HTTP_TIMEOUT = 30; - - public TokenValidatorConfig config; - private OkHttpClient httpClient; - private ObjectMapper mapper; - private Duration fetchTimeout; - private List verifiers; - private List algorithms; - - public Builder httpClient(OkHttpClient client) { - this.httpClient = client; - return this; - } - - public Builder objectMapper(ObjectMapper mapper) { - this.mapper = mapper; - return this; - } - - public Builder fetchTimeout(Duration timeout) { - this.fetchTimeout = timeout; - return this; - } - - public Builder config(TokenValidatorConfig config) { - this.config = config; - return this; - } - - public Builder verifiers(List verifiers) { - this.verifiers = verifiers; - return this; - } - - public Builder validators(List validators) { - this.algorithms = validators; - return this; - } - - private Builder verify() { - if (httpClient == null) { - httpClient = new OkHttpClient.Builder() - .connectTimeout(DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS) - .writeTimeout(DEFAULT_HTTP_TIMEOUT, TimeUnit.SECONDS) - .build(); - } else { - httpClient = httpClient.newBuilder().build(); - } - if (mapper == null) { - mapper = new ObjectMapper(); - } - if (fetchTimeout == null) { - fetchTimeout = FETCH_TIMEOUT_DEFAULT; - } - if (config == null) { - config = TokenVerifierPublicKeyConfig.readFromFileOrClasspath(); - } - if (algorithms == null) { - algorithms = AlgorithmLoader.defaultAlgorithms(); - } - if (verifiers == null) { - verifiers = List.of(); - } - return this; - } - - /** - * Build a new validator. - * @return built validator. - */ - public TokenValidator build() { - verify(); - return new TokenValidator(this); - } - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt new file mode 100644 index 000000000..16c827600 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt @@ -0,0 +1,156 @@ +package org.radarbase.auth.authentication + +import com.auth0.jwt.exceptions.AlgorithmMismatchException +import com.auth0.jwt.interfaces.DecodedJWT +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.consume +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.token.RadarToken +import org.radarbase.auth.util.CachedValue +import org.radarbase.auth.util.consumeFirst +import org.radarbase.auth.util.forkJoin +import org.slf4j.LoggerFactory +import java.time.Duration + +private typealias TokenVerifierCache = CachedValue> + +/** + * Validates JWT token signed by the Management Portal. It may be used from multiple threads. + * If the status of the public key should be checked immediately, call + * [.refresh] directly after creating this validator. It currently does not check this, so + * that the validator can be used even if a remote ManagementPortal is not reachable during + * construction. + */ +class TokenValidator +@JvmOverloads +constructor( + /** Loaders for token verifiers to use in the token authenticator. */ + verifierLoaders: List, + /** Minimum fetch timeout before a token is attempted to be fetched again. */ + fetchTimeout: Duration = Duration.ofMinutes(1), + /** Maximum time that the token verifier does not need to be fetched. */ + maxAge: Duration = Duration.ofDays(1), +) { + private val algorithmLoaders: List = verifierLoaders.map { loader -> + CachedValue( + minAge = fetchTimeout, + maxAge = maxAge, + ) { + loader.fetch() + } + } + + /** + * Validates an access token and returns the token as a [RadarToken] object. + * + * If we have not yet fetched the JWT public key, this method will fetch it. If a signature can + * not be verified, this method will fetch the JWT public key again, as it might have been + * changed, and re-check the token. However, this fetching of the public key will only be + * performed at most once every `fetchTimeout` seconds, to prevent (malicious) + * clients from making us call the token endpoint too frequently. + * + * @param token The access token + * @return The decoded access token + * @throws TokenValidationException If the token can not be validated. + */ + @Throws(TokenValidationException::class) + fun authenticateBlocking(token: String): RadarToken = runBlocking { + authenticate(token) + } + + /** + * Validates an access token and returns the decoded JWT as a [DecodedJWT] object. + * + * If we have not yet fetched the JWT public key, this method will fetch it. If a signature can + * not be verified, this method will fetch the JWT public key again, as it might have been + * changed, and re-check the token. However, this fetching of the public key will only be + * performed at most once every `fetchTimeout` seconds, to prevent (malicious) + * clients from making us call the token endpoint too frequently. + * + * @param token The access token + * @return The decoded access token + * @throws TokenValidationException If the token can not be validated. + */ + @Throws(TokenValidationException::class) + suspend fun authenticate(token: String): RadarToken { + val result: Result = consumeFirst { channel -> + val errors = algorithmLoaders + .forkJoin { cache -> + val result = cache.verify(token) + // short-circuit to return the first successful result + if (result.isSuccess) channel.send(result) + result + } + .mapNotNull { it.exceptionOrNull() } + .flatMap { it.suppressedExceptions } + + val suppressedMessage = errors.joinToString { it.message ?: it.javaClass.simpleName } + channel.send( + TokenValidationException("No registered validator in could authenticate this token: $suppressedMessage") + .toFailure(errors) + ) + } + + return result.getOrThrow() + } + + /** Refresh the token verifiers from cache on the next validation. */ + fun refresh() { + algorithmLoaders.forEach { it.clear() } + } + + /** + * Verify the token using the TokenVerifier lists from cache. + * If verification fails and the TokenVerifier list was retrieved from cache + * try to reload the TokenVerifier list and verify again. + * If none of the verifications succeed, return a result of TokenValidationException + * with suppressed exceptions all the exceptions returned from a TokenVerifier. + */ + private suspend fun TokenVerifierCache.verify(token: String): Result { + val verifiers = getOrEmpty(false) + + var results = verifiers.value.map { it.runCatching { verify(token) } } + results.find { it.isSuccess }?.let { return it } + + // already fetched a new value, no need to fetch it again + if (verifiers is CachedValue.CacheMiss) return results.toValidationExceptionResult() + + val refreshedVerifiers = getOrEmpty(true) + if (refreshedVerifiers == verifiers) return results.toValidationExceptionResult() + + results = refreshedVerifiers.value.map { it.runCatching { verify(token) } } + results.find { it.isSuccess }?.let { return it } + return results.toValidationExceptionResult() + } + + companion object { + private val logger = LoggerFactory.getLogger(TokenValidator::class.java) + + private suspend fun TokenVerifierCache.getOrEmpty( + refresh: Boolean + ): CachedValue.CacheResult> = + try { + get(refresh) + } catch (ex: Throwable) { + logger.warn("Failed to load authentication algorithm keys: {}", ex.message) + CachedValue.CacheMiss(emptyList()) + } + + private fun List>.toValidationExceptionResult(): Result { + val exceptions = mapNotNull { result -> + result.exceptionOrNull() + ?.takeIf { it !is AlgorithmMismatchException } + } + + return TokenValidationException("Failed to validate token") + .toFailure(exceptions) + } + + private fun Throwable.toFailure(causes: Iterable = emptyList()): Result { + causes.forEach { addSuppressed(it) } + return Result.failure(this) + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifier.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifier.kt new file mode 100644 index 000000000..567e0c1da --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifier.kt @@ -0,0 +1,16 @@ +package org.radarbase.auth.authentication + +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.token.RadarToken + +/** + * Verifies a token from string and returns a parsed version of it. + */ +interface TokenVerifier { + /** + * Verifies a token from string and returns a parsed version of it. + * @throws TokenValidationException if the token cannot be verified by this verifier. + */ + @Throws(TokenValidationException::class) + fun verify(token: String): RadarToken +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifierLoader.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifierLoader.kt new file mode 100644 index 000000000..6a04ef815 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenVerifierLoader.kt @@ -0,0 +1,11 @@ +package org.radarbase.auth.authentication + +/** + * Factory to load a list of token verifiers. + */ +interface TokenVerifierLoader { + /** + * Fetch a list of token verifiers, possibly from an external resource. + */ + suspend fun fetch(): List +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt new file mode 100644 index 000000000..9fb55be5f --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt @@ -0,0 +1,22 @@ +package org.radarbase.auth.authorization + +data class AuthorityReferenceSet( + /** Identity has global authority. */ + val global: Boolean = false, + /** Identity has explicit authority over these organizations. */ + val organizations: Set = emptySet(), + /** Identity has explicit authority over these projects. */ + val projects: Set = emptySet(), +) { + /** Identity does not have any authority. */ + fun isEmpty(): Boolean = !global && organizations.isEmpty() && projects.isEmpty() + + /** Identity has authority over the given [organization]. */ + fun hasOrganization(organization: String): Boolean = organization in organizations + + /** Identity has authority over the given [project]. */ + fun hasProject(project: String) = project in projects + + /** Whether identity has explicit authority over any projects. */ + fun hasProjects() = projects.isNotEmpty() +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt new file mode 100644 index 000000000..dcff0ce21 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt @@ -0,0 +1,359 @@ +package org.radarbase.auth.authorization + +import org.radarbase.auth.exception.NotAuthorizedException +import org.radarbase.auth.token.AuthorityReference +import org.radarbase.auth.token.RadarToken +import java.util.* +import java.util.function.Consumer + +class AuthorizationOracle( + private val relationService: EntityRelationService +) { + /** + * Check whether [identity] has permission [permission], regarding given [entity]. The + * entity can be constructed using a builder pattern. The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws NotAuthorizedException if identity does not have permission + */ + @Throws(NotAuthorizedException::class) + fun checkPermission( + identity: RadarToken, + permission: Permission, + entity: Consumer + ) = checkPermission(identity, permission, EntityDetails().apply { entity.accept(this) }) + + /** + * Check whether [identity] has permission [permission], regarding given [entity]. An additional + * [entityScope] can be provided to check whether the permission is also valid regarding that + * scope. The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws NotAuthorizedException if identity does not have permission + */ + @JvmOverloads + @Throws(NotAuthorizedException::class) + fun checkPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails = EntityDetails.global, + entityScope: Permission.Entity = permission.entity, + ) { + if (!hasPermission(identity, permission, entity, entityScope)) { + throw NotAuthorizedException( + "User ${identity.username} with client ${identity.clientId} does not have permission $permission to scope " + + "$entityScope of $entity" + ) + } + } + + /** + * Whether [identity] has permission [permission], regarding given [entity]. An additional + * [entityScope] can be provided to check whether the permission is also valid regarding that + * scope. The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @return true if identity has permission, false otheriwse + */ + @JvmOverloads + fun hasPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails = EntityDetails.global, + entityScope: Permission.Entity = permission.entity, + ): Boolean { + if (permission.scope() !in identity.scopes) return false + + if (identity.isClientCredentials) return true + + return identity.roles.any { + it.hasPermission(identity, permission, entity, entityScope) + } + } + + /** + * Whether [identity] has permission [permission], regarding given [entity]. The + * entity can be constructed using a builder pattern. The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @return true if identity has permission, false otheriwse + */ + fun hasPermission( + identity: RadarToken, + permission: Permission, + entity: Consumer + ) = hasPermission(identity, permission, EntityDetails().apply { entity.accept(this) }) + + /** + * Check whether given [identity] would have the [permission] scope in any of its roles. This doesn't + * check whether [identity] has access to a specific entity or global access. + * @throws NotAuthorizedException if identity does not have scope + */ + @Throws(NotAuthorizedException::class) + fun checkScope( + identity: RadarToken, + permission: Permission, + ) { + if (!hasScope(identity, permission)) { + throw NotAuthorizedException( + "User ${identity.username} with client ${identity.clientId} does not have permission $permission" + ) + } + } + + /** + * Whether given [identity] would have the [permission] scope in any of its roles. This doesn't + * check whether [identity] has access to a specific entity or global access. + * @return true if identity has scope, false otherwise + */ + fun hasScope(identity: RadarToken, permission: Permission): Boolean { + if (permission.scope() !in identity.scopes) return false + + if (identity.isClientCredentials) return true + + return identity.roles.any { it.role.mayBeGranted(permission) } + } + + /** + * Return a list of referents, per scope, that given [identity] has given [permission] on. + * The GLOBAL scope does not have any referents, so that will always return an empty list. + * The ORGANIZATION scope will give a list of organization names, and the PROJECT scope a list + * of project names. If identity has no role with given permission, this will return an empty + * map. + */ + fun referentsByScope( + identity: RadarToken, + permission: Permission + ): AuthorityReferenceSet { + var global = false + val organizations = mutableSetOf() + val projects = mutableSetOf() + + identity.roles.forEach { + if (it.role.mayBeGranted(permission)) { + when (it.role.scope) { + RoleAuthority.Scope.GLOBAL -> global = true + RoleAuthority.Scope.ORGANIZATION -> organizations.add(it.referent!!) + RoleAuthority.Scope.PROJECT -> projects.add(it.referent!!) + } + } + } + + return AuthorityReferenceSet( + global = global, + organizations = organizations, + projects = projects + ) + } + + /** + * Whether the current role from [identity] has [permission] over given [entity] in + * [entityScope] in any way. + */ + private fun AuthorityReference.hasPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean { + if (!role.mayBeGranted(permission)) return false + if (role.scope == RoleAuthority.Scope.GLOBAL) return true + // if no entity scope is available and the role scope is not global, no matching authority + // can be found. + val minEntityScope = entity.minimumEntityOrNull() ?: return false + return hasAuthority(identity, permission, entity, entityScope) && + (entityScope == minEntityScope || + hasAuthority(identity, permission, entity, minEntityScope)) + } + + /** + * Whether the current role from [identity] has a specific authority with [permission] + * over given [entity] in [entityScope] + */ + private fun AuthorityReference.hasAuthority( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean = when (entityScope) { + Permission.Entity.MEASUREMENT -> hasAuthority(identity, permission, entity, Permission.Entity.SOURCE) + Permission.Entity.SOURCE -> (!role.isPersonal || + // no specific source is mentioned -> just check the subject + entity.source == null || + entity.source in identity.sources) && + hasAuthority(identity, permission, entity, Permission.Entity.SUBJECT) + Permission.Entity.SUBJECT -> (!role.isPersonal || + entity.subject == identity.subject) && + hasAuthority(identity, permission, entity, Permission.Entity.PROJECT) + Permission.Entity.PROJECT -> when (role.scope) { + RoleAuthority.Scope.PROJECT -> referent == entity.project + RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent + else -> false + } + Permission.Entity.ORGANIZATION -> when (role.scope) { + RoleAuthority.Scope.PROJECT -> referent == entity.project || entity.organizationContainsProject(referent!!) + RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent + else -> false + } + Permission.Entity.USER -> entity.user == identity.username || !role.isPersonal + else -> true + } + + private fun EntityDetails.findOrganization(): String? { + organization?.let { return it } + val p = project ?: return null + return relationService.findOrganizationOfProject(p) + .also { this.organization = it } + } + + private fun EntityDetails.organizationContainsProject(targetProject: String): Boolean { + val org = findOrganization() ?: return false + return relationService.organizationContainsProject(org, targetProject) + } + + /** + * Created by dverbeec on 22/09/2017. + */ + companion object Permissions { + /** + * Get the permission matrix. + * + * + * The permission matrix maps each [Permission] to a set of authorities that have that + * permission. + * @return An unmodifiable view of the permission matrix. + */ + @JvmStatic + val permissionMatrix: Map> = createPermissions() + + /** + * Look up the allowed authorities for a given permission. Authorities are String constants that + * appear in [RoleAuthority]. + * @param permission The permission to look up. + * @return An unmodifiable view of the set of allowed authorities. + */ + @JvmStatic + fun allowedRoles(permission: Permission): Set { + return permissionMatrix[permission] ?: emptySet() + } + + /** + * Static permission matrix based on the currently agreed upon security rules. + */ + private fun createPermissions(): Map> { + val rolePermissions: MutableMap> = + EnumMap(RoleAuthority::class.java) + + // System admin can do everything. + rolePermissions[RoleAuthority.SYS_ADMIN] = Permission.values().asSequence() + + // Organization admin can do most things, but not view subjects or measurements + rolePermissions[RoleAuthority.ORGANIZATION_ADMIN] = Permission.values().asSequence() + .exclude( + Permission.ORGANIZATION_CREATE, + Permission.SOURCEDATA_CREATE, + Permission.SOURCETYPE_CREATE + ) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.MEASUREMENT + ) + + // for all authorities except for SYS_ADMIN, the authority is scoped to a project, which + // is checked elsewhere + // Project Admin - has all currently defined permissions except creating new projects + // Note: from radar-auth:0.5.7 we allow PROJECT_ADMIN to create measurements. + // This can be done by uploading data through the web application. + rolePermissions[RoleAuthority.PROJECT_ADMIN] = Permission.values().asSequence() + .exclude(Permission.PROJECT_CREATE) + .excludeEntities(Permission.Entity.AUDIT, Permission.Entity.AUTHORITY) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + + /* Project Owner */ + // CRUD operations on subjects to allow enrollment + rolePermissions[RoleAuthority.PROJECT_OWNER] = Permission.values().asSequence() + .exclude(Permission.PROJECT_CREATE) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + + /* Project affiliate */ + // Create, read and update participant (no delete) + rolePermissions[RoleAuthority.PROJECT_AFFILIATE] = Permission.values().asSequence() + .exclude(Permission.SUBJECT_DELETE) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + .limitEntityOperations(Permission.Entity.PROJECT, Permission.Operation.READ) + + /* Project analyst */ + // Can read everything except users, authorities and audits + rolePermissions[RoleAuthority.PROJECT_ANALYST] = Permission.values().asSequence() + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + // Can add metadata to sources, only read other things. + .filter { p -> + p.operation == Permission.Operation.READ || + p == Permission.SUBJECT_UPDATE + } + + /* Participant */ + // Can update and read own data and can read and write own measurements + rolePermissions[RoleAuthority.PARTICIPANT] = sequenceOf( + Permission.SUBJECT_READ, + Permission.SUBJECT_UPDATE, + Permission.MEASUREMENT_CREATE, + Permission.MEASUREMENT_READ + ) + + /* Inactive participant */ + // Doesn't have any permissions + rolePermissions[RoleAuthority.INACTIVE_PARTICIPANT] = emptySequence() + + // invert map + return rolePermissions.asSequence() + .flatMap { (role, permissionSeq) -> + permissionSeq.map { p -> Pair(p, role) } + } + .groupingBy { (p, _) -> p } + .foldTo( + EnumMap(Permission::class.java), + initialValueSelector = { _, (_, role) -> enumSetOf(role) }, + operation = { _, set, (_, role) -> + set += role + set + } + ) + } + + private fun Sequence.limitEntityOperations( + entity: Permission.Entity, + vararg operations: Permission.Operation + ): Sequence { + val operationSet = enumSetOf(*operations) + return filter { p: Permission -> p.entity != entity || p.operation in operationSet } + } + + private fun Sequence.exclude(vararg permissions: Permission): Sequence { + val permissionSet = enumSetOf(*permissions) + return filter { it !in permissionSet } + } + + private fun Sequence.excludeEntities(vararg entities: Permission.Entity): Sequence { + val entitySet = enumSetOf(*entities) + return filter { it.entity !in entitySet } + } + + private inline fun > enumSetOf(vararg values: T): EnumSet = EnumSet.noneOf( + T::class.java + ).apply { + values.forEach { add(it) } + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt new file mode 100644 index 000000000..934aefda3 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt @@ -0,0 +1,62 @@ +package org.radarbase.auth.authorization + +import java.util.function.Consumer + +/** Entity details to check with AuthorizationOracle. */ +data class EntityDetails( + /** Organization name. */ + var organization: String? = null, + /** Project name. */ + var project: String? = null, + /** Subject login. */ + var subject: String? = null, + /** User login. */ + var user: String? = null, + /** Source name */ + var source: String? = null, +) { + /** + * Return the entity most basic in this EntityDetails. + * If no field is set, e.g. this is a global Entity, returns null. + */ + fun minimumEntityOrNull(): Permission.Entity? = when { + user != null -> Permission.Entity.USER + source != null -> Permission.Entity.SOURCE + subject != null -> Permission.Entity.SUBJECT + project != null -> Permission.Entity.PROJECT + organization != null -> Permission.Entity.ORGANIZATION + else -> null + } + + fun organization(organization: String?) = apply { + this.organization = organization + } + + fun project(project: String?) = apply { + this.project = project + } + + fun subject(subject: String?) = apply { + this.subject = subject + } + + fun user(user: String?) = apply { + this.user = user + } + + fun source(source: String?) = apply { + this.source = source + } + + companion object { + val global = EntityDetails() + } +} + +inline fun entityDetails( + config: EntityDetails.() -> Unit, +): EntityDetails = EntityDetails().apply(config) + +fun entityDetailsBuilder( + config: Consumer +): EntityDetails = entityDetails(config::accept) diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt new file mode 100644 index 000000000..f35b43cf3 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt @@ -0,0 +1,11 @@ +package org.radarbase.auth.authorization + +/** Service to determine the relationship between entities. */ +interface EntityRelationService { + /** From a [project] name, return an organization name. */ + fun findOrganizationOfProject(project: String): String + + /** Whether given [organization] name has a [project] with given name. */ + fun organizationContainsProject(organization: String, project: String): Boolean = + findOrganizationOfProject(project) == organization +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.java b/radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.kt similarity index 50% rename from radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.java rename to radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.kt index 7ecf199c9..83872587e 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.java +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/Permission.kt @@ -1,151 +1,101 @@ -package org.radarbase.auth.authorization; - -import java.util.Arrays; -import java.util.stream.Stream; +package org.radarbase.auth.authorization /** * Class to represent the different permissions in the RADAR platform. A permission has an entity * and an operation. */ -public enum Permission { +enum class Permission(val entity: Entity, val operation: Operation) { SOURCETYPE_CREATE(Entity.SOURCETYPE, Operation.CREATE), SOURCETYPE_READ(Entity.SOURCETYPE, Operation.READ), SOURCETYPE_UPDATE(Entity.SOURCETYPE, Operation.UPDATE), SOURCETYPE_DELETE(Entity.SOURCETYPE, Operation.DELETE), + SOURCEDATA_CREATE(Entity.SOURCEDATA, Operation.CREATE), SOURCEDATA_READ(Entity.SOURCEDATA, Operation.READ), SOURCEDATA_UPDATE(Entity.SOURCEDATA, Operation.UPDATE), SOURCEDATA_DELETE(Entity.SOURCEDATA, Operation.DELETE), + SOURCE_CREATE(Entity.SOURCE, Operation.CREATE), SOURCE_READ(Entity.SOURCE, Operation.READ), SOURCE_UPDATE(Entity.SOURCE, Operation.UPDATE), SOURCE_DELETE(Entity.SOURCE, Operation.DELETE), + SUBJECT_CREATE(Entity.SUBJECT, Operation.CREATE), SUBJECT_READ(Entity.SUBJECT, Operation.READ), SUBJECT_UPDATE(Entity.SUBJECT, Operation.UPDATE), SUBJECT_DELETE(Entity.SUBJECT, Operation.DELETE), + USER_CREATE(Entity.USER, Operation.CREATE), USER_READ(Entity.USER, Operation.READ), USER_UPDATE(Entity.USER, Operation.UPDATE), USER_DELETE(Entity.USER, Operation.DELETE), + ROLE_CREATE(Entity.ROLE, Operation.CREATE), ROLE_READ(Entity.ROLE, Operation.READ), ROLE_UPDATE(Entity.ROLE, Operation.UPDATE), ROLE_DELETE(Entity.ROLE, Operation.DELETE), + PROJECT_CREATE(Entity.PROJECT, Operation.CREATE), PROJECT_READ(Entity.PROJECT, Operation.READ), PROJECT_UPDATE(Entity.PROJECT, Operation.UPDATE), PROJECT_DELETE(Entity.PROJECT, Operation.DELETE), + ORGANIZATION_CREATE(Entity.ORGANIZATION, Operation.CREATE), ORGANIZATION_READ(Entity.ORGANIZATION, Operation.READ), ORGANIZATION_UPDATE(Entity.ORGANIZATION, Operation.UPDATE), ORGANIZATION_DELETE(Entity.ORGANIZATION, Operation.DELETE), + OAUTHCLIENTS_CREATE(Entity.OAUTHCLIENTS, Operation.CREATE), OAUTHCLIENTS_READ(Entity.OAUTHCLIENTS, Operation.READ), OAUTHCLIENTS_UPDATE(Entity.OAUTHCLIENTS, Operation.UPDATE), OAUTHCLIENTS_DELETE(Entity.OAUTHCLIENTS, Operation.DELETE), + AUDIT_READ(Entity.AUDIT, Operation.READ), + AUTHORITY_READ(Entity.AUTHORITY, Operation.READ), + MEASUREMENT_READ(Entity.MEASUREMENT, Operation.READ), MEASUREMENT_CREATE(Entity.MEASUREMENT, Operation.CREATE); - public enum Entity { + enum class Entity { // ManagementPortal entities - SOURCETYPE, - SOURCEDATA, - SOURCE, - SUBJECT, - USER, - ROLE, - ORGANIZATION, - PROJECT, - OAUTHCLIENTS, - AUDIT, - AUTHORITY, - + SOURCETYPE, SOURCEDATA, SOURCE, SUBJECT, USER, ROLE, ORGANIZATION, PROJECT, OAUTHCLIENTS, + AUDIT, AUTHORITY, // RMT measurements MEASUREMENT } - public enum Operation { - CREATE, - READ, - UPDATE, - DELETE - } - - private final Entity entity; - private final Operation operation; - - /** - * Permission constructor. In general, the constants in this class should be preferred - * for referencing permissions. - * @param entity the entity that the permission refers to. - * @param operation the operation on given entity that requires a permission. - */ - Permission(Entity entity, Operation operation) { - if (entity == null || operation == null) { - throw new IllegalArgumentException("Entity and operation can not be null"); - } - this.entity = entity; - this.operation = operation; - } - - public Entity getEntity() { - return entity; + enum class Operation { + CREATE, READ, UPDATE, DELETE } - public Operation getOperation() { - return operation; - } - - /** - * Check if a given authority has this permission associated with it. - * @param authority the authority name - * @return true if the given authority has this permission associated with it, false otherwise - */ - public boolean isRoleAllowed(RoleAuthority authority) { - return Permissions.allowedRoles(this).contains(authority); - } - - @Override - public String toString() { - return "Permission{entity=" + entity + ", operation=" + operation + '}'; - } - - /** - * Stream all available permissions. - */ - public static Stream stream() { - return Arrays.stream(values()); - } - - /** Returns all available scope names. */ - public static String[] scopes() { - return stream() - .map(Permission::scope) - .toArray(String[]::new); - } - - /** Return matching permission. */ - public static Permission of(Entity entity, Operation operation) { - return stream() - .filter(p -> p.getEntity() == entity && p.getOperation() == operation) - .findAny() - .orElseThrow(() -> new IllegalArgumentException( - "No permission found for given entity and operation")); - } - - public static Permission ofScope(String scope) { - return valueOf(scope.replace('.', '_')); - } + override fun toString(): String = "Permission{entity=$entity, operation=$operation}" /** * Turn this permission into an OAuth scope name and return it. * * @return the OAuth scope representation of this permission */ - public String scope() { - return entity + "." + operation; + fun scope(): String = "$entity.$operation" + + companion object { + /** Returns all available scope names. */ + @JvmStatic + fun scopes(): Array { + return values() + .map { obj: Permission -> obj.scope() } + .toTypedArray() + } + + /** Return matching permission. */ + fun of(entity: Entity, operation: Operation): Permission = + requireNotNull( + values().firstOrNull { p -> p.entity == entity && p.operation == operation } + ) { "No permission found for given entity and operation" } + + @JvmStatic + fun ofScope(scope: String): Permission { + return valueOf(scope.replace('.', '_')) + } } } diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/Permissions.java b/radar-auth/src/main/java/org/radarbase/auth/authorization/Permissions.java deleted file mode 100644 index 14e076854..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/Permissions.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.radarbase.auth.authorization; - -import java.util.Arrays; -import java.util.Collections; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.Map; -import java.util.Set; -import java.util.function.Predicate; -import java.util.stream.Collector; -import java.util.stream.Stream; - -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.mapping; -import static java.util.stream.Collectors.toCollection; -import static org.radarbase.auth.authorization.RoleAuthority.INACTIVE_PARTICIPANT; -import static org.radarbase.auth.authorization.RoleAuthority.ORGANIZATION_ADMIN; -import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; -import static org.radarbase.auth.authorization.RoleAuthority.PROJECT_ADMIN; -import static org.radarbase.auth.authorization.RoleAuthority.PROJECT_AFFILIATE; -import static org.radarbase.auth.authorization.RoleAuthority.PROJECT_ANALYST; -import static org.radarbase.auth.authorization.RoleAuthority.PROJECT_OWNER; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; - -/** - * Created by dverbeec on 22/09/2017. - */ -public final class Permissions { - - private static final Map> PERMISSION_MATRIX; - - static { - PERMISSION_MATRIX = createPermissions(); - } - - private Permissions() { - // utility class - } - - /** - * Look up the allowed authorities for a given permission. Authorities are String constants that - * appear in {@link RoleAuthority}. - * @param permission The permission to look up. - * @return An unmodifiable view of the set of allowed authorities. - */ - public static Set allowedRoles(Permission permission) { - return PERMISSION_MATRIX.getOrDefault(permission, Set.of()); - } - - /** - * Get the permission matrix. - * - *

The permission matrix maps each {@link Permission} to a set of authorities that have that - * permission.

- * @return An unmodifiable view of the permission matrix. - */ - public static Map> getPermissionMatrix() { - return PERMISSION_MATRIX; - } - - /** - * Static permission matrix based on the currently agreed upon security rules. - */ - private static Map> createPermissions() { - Map> rolePermissions = new EnumMap<>( - RoleAuthority.class); - - // System admin can do everything. - rolePermissions.put(SYS_ADMIN, Permission.stream()); - - // Organization admin can do most things, but not view subjects or measurements - rolePermissions.put(ORGANIZATION_ADMIN, Permission.stream() - .filter(excludePermissions(Permission.ORGANIZATION_CREATE)) - .filter(excludeEntities(Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, Permission.Entity.MEASUREMENT)) - .filter(excludePermissions(Permission.SOURCEDATA_CREATE, - Permission.SOURCETYPE_CREATE))); - - // for all authorities except for SYS_ADMIN, the authority is scoped to a project, which - // is checked elsewhere - // Project Admin - has all currently defined permissions except creating new projects - // Note: from radar-auth:0.5.7 we allow PROJECT_ADMIN to create measurements. - // This can be done by uploading data through the web application. - rolePermissions.put(PROJECT_ADMIN, Permission.stream() - .filter(excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY)) - .filter(excludeOtherEntityOperations( - Permission.Entity.ORGANIZATION, Permission.Operation.READ)) - .filter(excludePermissions(Permission.PROJECT_CREATE))); - - /* Project Owner */ - // CRUD operations on subjects to allow enrollment - rolePermissions.put(PROJECT_OWNER, Permission.stream() - .filter(excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER)) - .filter(excludeOtherEntityOperations( - Permission.Entity.ORGANIZATION, Permission.Operation.READ)) - .filter(excludePermissions(Permission.PROJECT_CREATE))); - - /* Project affiliate */ - // Create, read and update participant (no delete) - rolePermissions.put(PROJECT_AFFILIATE, Permission.stream() - .filter(excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER)) - .filter(excludeOtherEntityOperations( - Permission.Entity.ORGANIZATION, Permission.Operation.READ)) - .filter(excludeOtherEntityOperations( - Permission.Entity.PROJECT, Permission.Operation.READ)) - .filter(excludePermissions( - Permission.SUBJECT_DELETE))); - - /* Project analyst */ - // Can read everything except users, authorities and audits - rolePermissions.put(PROJECT_ANALYST, Permission.stream() - .filter(excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER)) - // Can add metadata to sources, only read other things. - .filter(p -> p.getOperation() == Permission.Operation.READ - || p == Permission.SUBJECT_UPDATE)); - - /* Participant */ - // Can update and read own data and can read and write own measurements - rolePermissions.put(PARTICIPANT, Stream.of( - Permission.SUBJECT_READ, - Permission.SUBJECT_UPDATE, - Permission.MEASUREMENT_CREATE, - Permission.MEASUREMENT_READ)); - - /* Inactive participant */ - // Doesn't have any permissions - rolePermissions.put(INACTIVE_PARTICIPANT, Stream.empty()); - - // invert map - return Collections.unmodifiableMap( - rolePermissions.entrySet().stream() - .flatMap(rolePerm -> rolePerm.getValue() - .map(p -> Map.entry(p, rolePerm.getKey()))) - .collect(groupingBy( - Map.Entry::getKey, - () -> new EnumMap<>(Permission.class), - mapping(Map.Entry::getValue, toUnmodifiableEnumSet())))); - } - - private static Collector> - toUnmodifiableEnumSet() { - return collectingAndThen( - toCollection(() -> EnumSet.noneOf(RoleAuthority.class)), - Collections::unmodifiableSet); - } - - private static Predicate excludeOtherEntityOperations( - Permission.Entity entity, - Permission.Operation... operations) { - return p -> p.getEntity() != entity || Arrays.asList(operations).contains(p.getOperation()); - } - - private static Predicate excludePermissions(Permission... permissions) { - return p -> !Arrays.asList(permissions).contains(p); - } - - private static Predicate excludeEntities(Permission.Entity... entities) { - return p -> !Arrays.asList(entities).contains(p.getEntity()); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/RadarAuthorization.java b/radar-auth/src/main/java/org/radarbase/auth/authorization/RadarAuthorization.java deleted file mode 100644 index 6ee9b58e1..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/RadarAuthorization.java +++ /dev/null @@ -1,205 +0,0 @@ -package org.radarbase.auth.authorization; - -import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Authorization helper class for RADAR. This class checks if the authenticated user is allowed to - * access the protected resources of a given subject based on the authorities and project - * affiliations. - */ -public final class RadarAuthorization { - - private static final Logger log = LoggerFactory.getLogger(RadarAuthorization.class); - - private RadarAuthorization() { - // utility class - } - - /** - * Similar to {@link RadarToken#hasAuthority(RoleAuthority)}, but this - * method throws an exception rather than returning a boolean. Useful in combination with e.g. - * Spring's controllers and exception translators. - * @param token The token of the requester - * @param authority The authority to check - * @throws NotAuthorizedException if the supplied token does not have the authority - */ - public static void checkAuthority(RadarToken token, RoleAuthority authority) - throws NotAuthorizedException { - if (!token.isClientCredentials()) { - log.debug("Checking authority {} for user {}", authority, - token.getUsername()); - if (!token.hasAuthority(authority)) { - throw new NotAuthorizedException(String.format("Request Client %s does not have " - + "authority %s", token.getUsername(), authority)); - } - } - } - - - /** - * Similar to {@link RadarToken#hasAuthority(RoleAuthority)}, but this method throws an - * exception rather than returning a boolean. Useful in combination with e.g. Spring's - * controllers and exception translators. - * @param token The token of the requester - * @param authority The authority to check - * @param permission Permission that the authority should have. - * @throws NotAuthorizedException if the supplied token does not have the authority - */ - public static void checkAuthorityAndPermission(RadarToken token, RoleAuthority authority, - Permission permission) - throws NotAuthorizedException { - log.debug("Checking authority {} and permission {} for user {}", authority, permission, - token.getUsername()); - checkAuthority(token, authority); - checkPermission(token, permission); - } - - /** - * Similar to {@link RadarToken#hasPermission(Permission)}, but this method throws an - * exception rather than returning a boolean. Useful in combination with e.g. Spring's - * controllers and exception translators. - * @param token The token of the authenticated client - * @param permission The permission to check - * @throws NotAuthorizedException if the supplied token does not have the permission - */ - public static void checkPermission(RadarToken token, Permission permission) - throws NotAuthorizedException { - log.debug("Checking permission {} for user {}", permission.toString(), - token.getUsername()); - if (!token.hasPermission(permission)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s", token.getUsername(), permission)); - } - } - - /** - * Similar to {@link RadarToken#hasGlobalPermission(Permission)}, but this method throws an - * exception rather than returning a boolean. Useful in combination with e.g. Spring's - * controllers and exception translators. - * @param token The token of the authenticated client - * @param permission The permission to check - * @throws NotAuthorizedException if the supplied token does not have the permission - */ - public static void checkGlobalPermission(RadarToken token, Permission permission) - throws NotAuthorizedException { - log.debug("Checking global permission {} for user {}", permission.toString(), - token.getUsername()); - if (!token.hasGlobalPermission(permission)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s", token.getUsername(), permission)); - } - } - - /** - * Similar to {@link RadarToken#hasPermissionOnOrganization(Permission, String)}, - * but this method throws an exception rather than returning a boolean. - * Useful in combination with e.g. Spring's controllers and exception translators. - * @param token The token of the logged in user - * @param permission The permission to check - * @param organizationName The organization for which to check the permission - * @throws NotAuthorizedException if the supplied token - * does not have the permission in the given organization - */ - public static void checkPermissionOnOrganization(RadarToken token, Permission permission, - String organizationName) throws NotAuthorizedException { - log.debug("Checking permission {} for user {} in organization {}", permission.toString(), - token.getUsername(), organizationName); - if (!token.hasPermissionOnOrganization(permission, organizationName)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s in organization %s", - token.getUsername(), permission, organizationName)); - } - } - - /** - * Similar to {@link RadarToken#hasPermissionOnProject(Permission, String)}, but this method - * throws an exception rather than returning a boolean. Useful in combination with e.g. Spring's - * controllers and exception translators. - * @param token The token of the logged in user - * @param permission The permission to check - * @param projectName The project for which to check the permission - * @throws NotAuthorizedException if the supplied token does not have the permission in the - * given project - */ - public static void checkPermissionOnOrganizationAndProject(RadarToken token, - Permission permission, String organization, String projectName) - throws NotAuthorizedException { - log.debug("Checking permission {} for user {} in organization {} and project {}", - permission.toString(), token.getUsername(), organization, projectName); - if (!token.hasPermissionOnOrganizationAndProject(permission, organization, projectName)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s in organization %s and project %s", - token.getUsername(), permission, organization, - projectName)); - } - } - - /** - * Similar to {@link RadarToken#hasPermissionOnProject(Permission, String)}, but this method - * throws an exception rather than returning a boolean. Useful in combination with e.g. Spring's - * controllers and exception translators. - * @param token The token of the logged in user - * @param permission The permission to check - * @param projectName The project for which to check the permission - * @throws NotAuthorizedException if the supplied token does not have the permission in the - * given project - */ - public static void checkPermissionOnProject(RadarToken token, Permission permission, - String projectName) throws NotAuthorizedException { - log.debug("Checking permission {} for user {} in project {}", permission.toString(), - token.getUsername(), projectName); - if (!token.hasPermissionOnProject(permission, projectName)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s in project %s", token.getUsername(), permission, - projectName)); - } - } - - /** - * Similar to {@link RadarToken#hasPermissionOnSubject(Permission, String, String)}, but this - * method throws an exception rather than returning a boolean. Useful in combination with e.g. - * Spring's controllers and exception translators. - * @param token The token of the logged in user - * @param permission The permission to check - * @param projectName The project for which to check the permission - * @param subjectName The name of the subject to check - * @throws NotAuthorizedException if the supplied token does not have the permission in the - * given project for the given subject - */ - public static void checkPermissionOnSubject(RadarToken token, Permission permission, - String projectName, String subjectName) throws NotAuthorizedException { - log.debug("Checking permission {} for user {} on subject {} in project {}", - permission.toString(), token.getUsername(), subjectName, projectName); - if (!token.hasPermissionOnSubject(permission, projectName, subjectName)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s on subject %s in project %s", token.getUsername(), - permission, subjectName, projectName)); - } - } - - /** - * Similar to {@link RadarToken#hasPermissionOnSource(Permission, String, String, String)}, but - * this method throws an exception rather than returning a boolean. Useful in combination with, - * e.g., Spring's controllers and exception translators. - * @param token The token of the logged in user - * @param permission The permission to check - * @param projectName The project for which to check the permission - * @param subjectName The name of the subject to check - * @param sourceId The source ID to check - * @throws NotAuthorizedException if the supplied token does not have the permission in the - * given project for the given subject and source. - */ - public static void checkPermissionOnSource(RadarToken token, Permission permission, - String projectName, String subjectName, String sourceId) throws NotAuthorizedException { - log.debug("Checking permission {} for user {} on source {} of subject {} in project {}", - permission.toString(), token.getUsername(), sourceId, subjectName, projectName); - if (!token.hasPermissionOnSource(permission, projectName, subjectName, sourceId)) { - throw new NotAuthorizedException(String.format("Client %s does not have " - + "permission %s on source %s of subject %s in project %s", - token.getUsername(), permission, sourceId, subjectName, projectName)); - } - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.java b/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.java deleted file mode 100644 index 136008f8d..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.radarbase.auth.authorization; - -import java.io.Serializable; -import java.util.EnumSet; -import java.util.Locale; -import java.util.Set; -import java.util.stream.Collector; - -import static java.util.stream.Collectors.toCollection; - -/** - * Constants for Spring Security authorities. - */ -public enum RoleAuthority implements Serializable { - SYS_ADMIN(Scope.GLOBAL, false), - PROJECT_ADMIN(Scope.PROJECT, false), - PROJECT_OWNER(Scope.PROJECT, false), - PROJECT_AFFILIATE(Scope.PROJECT, false), - PROJECT_ANALYST(Scope.PROJECT, false), - PARTICIPANT(Scope.PROJECT, true), - INACTIVE_PARTICIPANT(Scope.PROJECT, true), - ORGANIZATION_ADMIN(Scope.ORGANIZATION, false); - - public static final String SYS_ADMIN_AUTHORITY = "ROLE_SYS_ADMIN"; - - private final Scope scope; - private final boolean isPersonal; - - RoleAuthority(Scope scope, boolean isPersonal) { - this.scope = scope; - this.isPersonal = isPersonal; - } - - public Scope scope() { - return scope; - } - - public String authority() { - return "ROLE_" + name(); - } - - public boolean isPersonal() { - return this.isPersonal; - } - - /** - * Find role authority based on authority name. - * @param authority authority name - * @return RoleAuthority - * @throws IllegalArgumentException if no role authority exists with the given name. - * @throws NullPointerException if given authority is null. - */ - public static RoleAuthority valueOfAuthority(String authority) { - String upperAuthority = authority.toUpperCase(Locale.ROOT); - if (!upperAuthority.startsWith("ROLE_")) { - throw new IllegalArgumentException("Cannot map role without 'ROLE_' prefix"); - } - return valueOf(upperAuthority.substring(5)); - } - - /** - * Find role authority based on authority name. - * @param authority authority name - * @return RoleAuthority or null if no role authority exists with the given name. - */ - public static RoleAuthority valueOfAuthorityOrNull(String authority) { - if (authority == null) { - return null; - } - try { - return valueOfAuthority(authority); - } catch (IllegalArgumentException ex) { - return null; - } - } - - public enum Scope { - GLOBAL, ORGANIZATION, PROJECT - } - - public static > Collector> toEnumSet(Class clazz) { - return toCollection(() -> EnumSet.noneOf(clazz)); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt new file mode 100644 index 000000000..bb937e13d --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt @@ -0,0 +1,68 @@ +package org.radarbase.auth.authorization + +import java.io.Serializable +import java.util.* +import java.util.stream.Collector +import java.util.stream.Collectors + +/** + * Constants for Spring Security authorities. + */ +enum class RoleAuthority( + val scope: Scope, + @JvmField val isPersonal: Boolean, +) : Serializable { + SYS_ADMIN(Scope.GLOBAL, false), + PROJECT_ADMIN(Scope.PROJECT, false), + PROJECT_OWNER(Scope.PROJECT, false), + PROJECT_AFFILIATE(Scope.PROJECT, false), + PROJECT_ANALYST(Scope.PROJECT, false), + PARTICIPANT(Scope.PROJECT, true), + INACTIVE_PARTICIPANT(Scope.PROJECT, true), + ORGANIZATION_ADMIN(Scope.ORGANIZATION, false); + + val authority: String = "ROLE_$name" + + enum class Scope { + GLOBAL, ORGANIZATION, PROJECT + } + + /** + * Check if this role has may have given permission associated with it. + * @param permission the permission to check + * @return true if this role has given permission associated with it, false otherwise + */ + fun mayBeGranted(permission: Permission) = this in AuthorizationOracle.allowedRoles(permission) + + companion object { + const val SYS_ADMIN_AUTHORITY = "ROLE_SYS_ADMIN" + + /** + * Find role authority based on authority name. + * @param authority authority name + * @return RoleAuthority + * @throws IllegalArgumentException if no role authority exists with the given name. + * @throws NullPointerException if given authority is null. + */ + @JvmStatic + fun valueOfAuthority(authority: String): RoleAuthority { + val upperAuthority = authority.uppercase() + require(upperAuthority.startsWith("ROLE_")) { "Cannot map role without 'ROLE_' prefix" } + return valueOf(upperAuthority.substring(5)) + } + + /** + * Find role authority based on authority name. + * @param authority authority name + * @return RoleAuthority or null if no role authority exists with the given name. + */ + @JvmStatic + fun valueOfAuthorityOrNull(authority: String): RoleAuthority? { + return try { + valueOfAuthority(authority) + } catch (ex: IllegalArgumentException) { + null + } + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/config/TokenValidatorConfig.java b/radar-auth/src/main/java/org/radarbase/auth/config/TokenValidatorConfig.java deleted file mode 100644 index 4c3898a09..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/config/TokenValidatorConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.radarbase.auth.config; - -import java.net.URI; -import java.util.List; - -public interface TokenValidatorConfig { - - /** - * Get the public key endpoint as a URI. - * @return The public key endpoint URI, or null if not defined - */ - List getPublicKeyEndpoints(); - - /** - * The name of this resource. It should be in the list of allowed resources for the OAuth - * client. - * @return the name of the resource - */ - String getResourceName(); - -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfig.java b/radar-auth/src/main/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfig.java deleted file mode 100644 index 521a35bbd..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfig.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.radarbase.auth.config; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; -import java.util.List; -import java.util.Objects; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import org.radarbase.auth.exception.ConfigurationException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Created by dverbeec on 14/06/2017. - */ -public class TokenVerifierPublicKeyConfig implements TokenValidatorConfig { - - private static final Logger log = LoggerFactory.getLogger(TokenVerifierPublicKeyConfig.class); - - public static final String LOCATION_ENV = "RADAR_IS_CONFIG_LOCATION"; - - private static final String CONFIG_FILE_NAME = "radar-is.yml"; - - private List publicKeyEndpoints = List.of(); - - private String resourceName; - - /** - * Read the configuration from file. This method will first check if the environment variable - * RADAR_IS_CONFIG_LOCATION is set. If not set, it will look for a file called - * radar_is.yml on the classpath. The configuration will be kept in a static field, - * so subsequent calls to this method will return the same object. - * - * @return The initialized configuration object based on the contents of the configuration file - * @throws ConfigurationException If there is any problem loading the configuration - */ - public static TokenVerifierPublicKeyConfig readFromFileOrClasspath() { - String customLocation = System.getenv(LOCATION_ENV); - URL configFile; - if (customLocation != null) { - log.info(LOCATION_ENV + " environment variable set, loading config from {}", - customLocation); - try { - configFile = new File(customLocation).toURI().toURL(); - } catch (MalformedURLException ex) { - throw new ConfigurationException(ex); - } - } else { - // if config location not defined, look for it on the classpath - log.info(LOCATION_ENV - + " environment variable not set, looking for it on the classpath"); - configFile = Thread.currentThread().getContextClassLoader() - .getResource(CONFIG_FILE_NAME); - - if (configFile == null) { - throw new ConfigurationException( - "Cannot find " + CONFIG_FILE_NAME + " file in classpath. "); - } - } - log.info("Config file found at {}", configFile.getPath()); - - ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - try (InputStream stream = configFile.openStream()) { - return mapper.readValue(stream, TokenVerifierPublicKeyConfig.class); - } catch (IOException ex) { - throw new ConfigurationException(ex); - } - } - - @Override - public List getPublicKeyEndpoints() { - return publicKeyEndpoints; - } - - public void setPublicKeyEndpoints(List publicKeyEndpoints) { - log.info("Token public key endpoints set to " + publicKeyEndpoints.toString()); - this.publicKeyEndpoints = publicKeyEndpoints; - } - - @Override - public String getResourceName() { - return resourceName; - } - - public void setResourceName(String resourceName) { - this.resourceName = resourceName; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof TokenVerifierPublicKeyConfig)) { - return false; - } - TokenVerifierPublicKeyConfig that = (TokenVerifierPublicKeyConfig) o; - return Objects.equals(publicKeyEndpoints, that.publicKeyEndpoints) - && Objects.equals(resourceName, that.resourceName); - } - - @Override - public int hashCode() { - return Objects.hash(publicKeyEndpoints, resourceName); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/ConfigurationException.java b/radar-auth/src/main/java/org/radarbase/auth/exception/ConfigurationException.java deleted file mode 100644 index a2ee1e4a7..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/exception/ConfigurationException.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.radarbase.auth.exception; - -/** - * Created by dverbeec on 20/09/2017. - */ -public class ConfigurationException extends RuntimeException { - public ConfigurationException() { - super(); - } - - public ConfigurationException(String message) { - super(message); - } - - public ConfigurationException(Throwable cause) { - super(cause); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/InvalidPublicKeyException.kt b/radar-auth/src/main/java/org/radarbase/auth/exception/InvalidPublicKeyException.kt new file mode 100644 index 000000000..514832c1f --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/exception/InvalidPublicKeyException.kt @@ -0,0 +1,7 @@ +package org.radarbase.auth.exception + +class InvalidPublicKeyException: TokenValidationException { + constructor(message: String) : super(message) + + constructor(message: String, cause: Throwable) : super(message, cause) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.java b/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.java deleted file mode 100644 index a8a36bd26..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.radarbase.auth.exception; - -import java.security.GeneralSecurityException; - -/** - * Created by dverbeec on 27/09/2017. - */ -public class NotAuthorizedException extends GeneralSecurityException { - public NotAuthorizedException() { - super(); - } - - public NotAuthorizedException(String message) { - super(message); - } - - public NotAuthorizedException(Throwable cause) { - super(cause); - } - - public NotAuthorizedException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt b/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt new file mode 100644 index 000000000..b10396ccf --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt @@ -0,0 +1,13 @@ +package org.radarbase.auth.exception + +import java.security.GeneralSecurityException + +/** + * Created by dverbeec on 27/09/2017. + */ +class NotAuthorizedException : GeneralSecurityException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?) : super(message, cause) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.java b/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.java deleted file mode 100644 index d00bf172f..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.radarbase.auth.exception; - -/** - * Created by dverbeec on 15/09/2017. - */ -public class TokenValidationException extends RuntimeException { - public TokenValidationException() { - super(); - } - - public TokenValidationException(String message) { - super(message); - } - - public TokenValidationException(Throwable cause) { - super(cause); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.kt b/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.kt new file mode 100644 index 000000000..725b077a1 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/exception/TokenValidationException.kt @@ -0,0 +1,10 @@ +package org.radarbase.auth.exception + +/** + * Created by dverbeec on 15/09/2017. + */ +open class TokenValidationException : RuntimeException { + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(cause: Throwable) : super(cause) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt new file mode 100644 index 000000000..4849dfcf2 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt @@ -0,0 +1,18 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_EC +import org.radarbase.auth.jwks.PEMCertificateParser.Companion.parsePublicKey + +class ECPEMCertificateParser : PEMCertificateParser { + override val jwtAlgorithm: String + get() = "SHA256withECDSA" + override val keyHeader: String + get() = "-----BEGIN EC PUBLIC KEY-----" + + override fun parseAlgorithm(publicKey: String): Algorithm = + Algorithm.ECDSA256(publicKey.parsePublicKey(keyFactoryType), null) + + override val keyFactoryType: String + get() = ALGORITHM_EC +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt new file mode 100644 index 000000000..7ec7f72c1 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt @@ -0,0 +1,112 @@ +package org.radarbase.auth.jwks + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.radarbase.auth.exception.InvalidPublicKeyException + +/** + * Represents the JavaWebKey for token verification. + */ +@Serializable(with = JavaWebKeyPolymorphicSerializer::class) +sealed interface JsonWebKey { + val alg: String? + val kty: String + /** X.509 Certificate Chain. */ + val x5c: List + /** X.509 Certificate SHA-1 thumbprint. */ + val x5t: String? + + companion object { + const val ALGORITHM_RSA = "RSA" + const val ALGORITHM_EC = "EC" + } +} + +object JavaWebKeyPolymorphicSerializer : JsonContentPolymorphicSerializer(JsonWebKey::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = if ("value" in element.jsonObject) { + MPJsonWebKey.serializer() + } else when (element.jsonObject["kty"]?.jsonPrimitive?.content) { + "EC" -> ECDSAJsonWebKey.serializer() + "RSA" -> RSAJsonWebKey.serializer() + else -> throw SerializationException("Unknown JavaWebKey") + } +} + +@Serializable +data class RSAJsonWebKey( + override val alg: String = KeySize.RS256.name, + override val kty: String, + val kid: String? = null, + val use: String = "sig", + /** RSA modulus. */ + val n: String, + /** RSA public exponent. */ + val e: String = "AQAB", + /** X.509 Certificate Chain. */ + override val x5c: List = emptyList(), + /** X.509 Certificate SHA-1 thumbprint. */ + override val x5t: String? = null, +) : JsonWebKey { + fun keySize(): KeySize = KeySize.valueOf(alg.uppercase()) + + enum class KeySize { + RS256, + RS384, + RS512; + } +} + +@Serializable +data class ECDSAJsonWebKey( + override val alg: String? = null, + override val kty: String, + val kid: String? = null, + val use: String = "sig", + /** ECDSA x coordinate. */ + val x: String, + /** ECDSA y coordinate. */ + val y: String, + /** ECDSA curve. */ + val crv: String, + /** X.509 Certificate Chain. */ + override val x5c: List = emptyList(), + /** X.509 Certificate SHA-1 thumbprint. */ + override val x5t: String? = null, +) : JsonWebKey { + fun keySize(): KeySize { + if (alg != null) { + return KeySize.valueOf(alg.uppercase()) + } + return when (crv) { + "P-256" -> KeySize.ES256 + "P-384" -> KeySize.ES384 + "P-521" -> KeySize.ES512 + else -> throw InvalidPublicKeyException("Unknown EC crv $crv") + } + } + + enum class KeySize(val ecStdName: String) { + ES256("secp256r1"), + ES384("secp384r1"), + ES512("secp521r1"); + } +} + +@Serializable +data class MPJsonWebKey( + override val alg: String = "ES256", + override val kty: String, + /** PEM certificate value */ + val value: String, + /** X.509 Certificate Chain. */ + override val x5c: List = emptyList(), + /** X.509 Certificate SHA-1 thumbprint. */ + override val x5t: String? = null, +) : JsonWebKey { + constructor(alg: String, kty: String, value: String) : this(alg, kty, value, emptyList(), null) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKeySet.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKeySet.kt new file mode 100644 index 000000000..522f83db0 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKeySet.kt @@ -0,0 +1,8 @@ +package org.radarbase.auth.jwks + +import kotlinx.serialization.Serializable + +@Serializable +data class JsonWebKeySet( + val keys: List = emptyList() +) diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt new file mode 100644 index 000000000..df610e0fe --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt @@ -0,0 +1,92 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import org.radarbase.auth.exception.InvalidPublicKeyException +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_EC +import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_RSA +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.* +import java.util.* + +class JwkAlgorithmParser( + private val supportedAlgorithmsForWebKeySets: List, +) : JwkParser { + + constructor() : this(listOf(ECPEMCertificateParser(), RSAPEMCertificateParser())) + + override fun parse(key: JsonWebKey): Algorithm { + if (key.x5c.isNotEmpty()) { + val x5cAlgorithm = supportedAlgorithmsForWebKeySets + .firstNotNullOfOrNull { parser -> + try { + parser.parseAlgorithm(key.x5c[0]) + } catch (ex: Exception) { + null + } + } + if (x5cAlgorithm != null) return x5cAlgorithm + } + + return when (key) { + is MPJsonWebKey -> supportedAlgorithmsForWebKeySets + .firstOrNull { algorithm -> key.value.startsWith(algorithm.keyHeader) } + ?.parseAlgorithm(key.value) + ?: throw TokenValidationException("Unsupported public key: $key") + is RSAJsonWebKey -> try { + val kf: KeyFactory = KeyFactory.getInstance(ALGORITHM_RSA) + val publicKeySpec = RSAPublicKeySpec( + BigInteger(1, Base64.getUrlDecoder().decode(key.n)), + BigInteger(1, Base64.getUrlDecoder().decode(key.e)) + ) + val publicKey = kf.generatePublic(publicKeySpec) as RSAPublicKey + when (key.keySize()) { + RSAJsonWebKey.KeySize.RS256 -> Algorithm.RSA256(publicKey, null) + RSAJsonWebKey.KeySize.RS384 -> Algorithm.RSA384(publicKey, null) + RSAJsonWebKey.KeySize.RS512 -> Algorithm.RSA512(publicKey, null) + } + } catch (e: GeneralSecurityException) { + throw InvalidPublicKeyException("Invalid public key", e) + } + is ECDSAJsonWebKey -> + try { + val keyFactory = KeyFactory.getInstance(ALGORITHM_EC) + val keySize = key.keySize() + val ecPublicKeySpec = ECPublicKeySpec( + ECPoint( + BigInteger(1, Base64.getUrlDecoder().decode(key.x)), + BigInteger(1, Base64.getUrlDecoder().decode(key.y)) + ), + AlgorithmParameters.getInstance(ALGORITHM_EC).run { + init(ECGenParameterSpec(keySize.ecStdName)) + getParameterSpec(ECParameterSpec::class.java) + } + ) + val publicKey = keyFactory.generatePublic(ecPublicKeySpec) as ECPublicKey + when (keySize) { + ECDSAJsonWebKey.KeySize.ES256 -> Algorithm.ECDSA256(publicKey, null) + ECDSAJsonWebKey.KeySize.ES384 -> Algorithm.ECDSA384(publicKey, null) + ECDSAJsonWebKey.KeySize.ES512 -> Algorithm.ECDSA512(publicKey, null) + } + } catch (e: NoSuchAlgorithmException) { + throw InvalidPublicKeyException("Invalid algorithm to generate key", e) + } catch (e: GeneralSecurityException) { + throw InvalidPublicKeyException("Invalid public key", e) + } + } + } + + override fun toString(): String = buildString(50) { + append("StringAlgorithmKeyLoader') + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkParser.kt new file mode 100644 index 000000000..b5e78774d --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkParser.kt @@ -0,0 +1,8 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import org.radarbase.auth.jwks.JsonWebKey + +interface JwkParser { + fun parse(key: JsonWebKey): Algorithm +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JwksTokenVerifierLoader.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwksTokenVerifierLoader.kt new file mode 100644 index 000000000..303d5cae5 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwksTokenVerifierLoader.kt @@ -0,0 +1,95 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.radarbase.auth.authentication.TokenVerifier +import org.radarbase.auth.authentication.TokenVerifierLoader +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.jwt.JwtRadarToken +import org.radarbase.auth.jwt.JwtTokenVerifier +import org.slf4j.LoggerFactory +import java.time.Duration + +class JwksTokenVerifierLoader( + private val url: String, + private val resourceName: String, + private val algorithmParser: JwkParser, +) : TokenVerifierLoader { + private val httpClient = HttpClient(CIO).config { + install(HttpTimeout) { + connectTimeoutMillis = Duration.ofSeconds(10).toMillis() + socketTimeoutMillis = Duration.ofSeconds(10).toMillis() + requestTimeoutMillis = Duration.ofSeconds(30).toMillis() + } + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + defaultRequest { + url(this@JwksTokenVerifierLoader.url) + accept(ContentType.Application.Json) + } + } + + override suspend fun fetch(): List { + val keySet = try { + fetchPublicKeyInfo() + } catch (ex: Exception) { + logger.warn("Failed to fetch token for {}: {}", url, ex.message) + return listOf() + } + return buildList(keySet.keys.size) { + keySet.keys.forEach { key -> + try { + add( + algorithmParser.parse(key) + .toTokenVerifier(resourceName) + ) + } catch (ex: Exception) { + logger.error("Failed to parse key from {}: {}", url, ex.message) + } + } + } + } + + private suspend fun fetchPublicKeyInfo(): JsonWebKeySet = withContext(Dispatchers.IO) { + logger.info("Getting the JWT public key at {}", url) + val response = httpClient.request() + + if (!response.status.isSuccess()) { + throw TokenValidationException("Cannot fetch token keys (${response.status}) - ${response.bodyAsText()}") + } + + response.body() + } + + override fun toString(): String = "MPTokenKeyAlgorithmKeyLoader" + + companion object { + @JvmStatic + fun Algorithm.toTokenVerifier(resourceName: String): JwtTokenVerifier { + val verifier = JWT.require(this).run { + withClaimPresence(JwtRadarToken.SCOPE_CLAIM) + withAudience(resourceName) + build() + } + return JwtTokenVerifier(name, verifier) + } + + private val logger = LoggerFactory.getLogger(JwksTokenVerifierLoader::class.java) + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/PEMCertificateParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/PEMCertificateParser.kt new file mode 100644 index 000000000..0525fc9c9 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/PEMCertificateParser.kt @@ -0,0 +1,57 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.* + +interface PEMCertificateParser { + /** + * The key factory type for keys that this algorithm can parse. + * @return the key factory type + */ + val keyFactoryType: String + + /** + * Get the algorithm description as it will be reported by the server public key endpoint + * (e.g. "SHA256withRSA" or "SHA256withEC"). + * @return the algorithm description + */ + val jwtAlgorithm: String + + /** + * Get the header for a PEM encoded key that this algorithm can parse. + * + * @return the header for a PEM encoded key that this algorithm can parse + */ + val keyHeader: String + + /** + * Build a verification algorithm based on the supplied public key. + * @param publicKey the public key in PEM format + * @return the verification algorithm + */ + fun parseAlgorithm(publicKey: String): Algorithm + + companion object { + /** + * Parse a public key in PEM format. + * @param publicKey the public key to parse + * @return a PublicKey object representing the supplied public key + */ + inline fun String.parsePublicKey(keyFactoryType: String): T { + val trimmedKey = replace("-----BEGIN ([A-Z]+ )?PUBLIC KEY-----".toRegex(), "") + .replace("-----END ([A-Z]+ )?PUBLIC KEY-----".toRegex(), "") + .trim { it <= ' ' } + return try { + val decodedPublicKey = Base64.getDecoder().decode(trimmedKey) + val spec = X509EncodedKeySpec(decodedPublicKey) + val kf = KeyFactory.getInstance(keyFactoryType) + kf.generatePublic(spec) as T + } catch (ex: Exception) { + throw IllegalArgumentException("Cannot parse public key with key factory type $keyFactoryType", ex) + } + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt new file mode 100644 index 000000000..1c830a7da --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt @@ -0,0 +1,17 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_RSA +import org.radarbase.auth.jwks.PEMCertificateParser.Companion.parsePublicKey + +class RSAPEMCertificateParser : PEMCertificateParser { + override val keyFactoryType: String + get() = ALGORITHM_RSA + override val jwtAlgorithm: String + get() = "SHA256withRSA" + override val keyHeader: String + get() = "-----BEGIN PUBLIC KEY-----" + + override fun parseAlgorithm(publicKey: String): Algorithm = + Algorithm.RSA256(publicKey.parsePublicKey(keyFactoryType), null) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt new file mode 100644 index 000000000..592af763b --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt @@ -0,0 +1,98 @@ +package org.radarbase.auth.jwt + +import com.auth0.jwt.exceptions.JWTDecodeException +import com.auth0.jwt.interfaces.DecodedJWT +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.AbstractRadarToken +import org.radarbase.auth.token.AuthorityReference +import java.util.* +import kotlin.collections.ArrayList + +/** + * Implementation of [RadarToken] based on JWT tokens. + * + * Initialize this `JwtRadarToken` based on the [DecodedJWT]. All relevant + * information will be parsed at construction time and no reference to the [DecodedJWT] + * is kept. Therefore, modifying the passed in [DecodedJWT] after this has been + * constructed will **not** update this object. + * @param jwt the JWT token to use to initialize this object + */ +class JwtRadarToken(private val jwt: DecodedJWT) : AbstractRadarToken() { + override val roles: Set = (jwt.parseAuthorities() + jwt.parseRoles()).toSet() + override val scopes: Set + override val sources: List = jwt.listClaim(SOURCES_CLAIM) + override val grantType: String = jwt.stringClaim(GRANT_TYPE_CLAIM) + override val subject: String = jwt.subject ?: "" + override val issuedAt: Date = jwt.issuedAt + override val expiresAt: Date = jwt.expiresAt + override val audience: List = jwt.audience ?: emptyList() + override val token: String = jwt.token ?: "" + override val issuer: String = jwt.issuer ?: "" + override val type: String = jwt.type ?: "" + override val clientId: String = jwt.stringClaim(CLIENT_ID_CLAIM) + override val username: String = jwt.stringClaim(USER_NAME_CLAIM) + + init { + val scopeClaim = jwt.getClaim(SCOPE_CLAIM) + val scopeClaimString = scopeClaim.asString() + scopes = scopeClaimString?.parseScopes() + ?: jwt.listClaim(SCOPE_CLAIM).toSet() + } + + override fun getClaimString(name: String): String? { + return jwt.getClaim(name).asString() + } + + override fun getClaimList(name: String): List { + return try { + jwt.listClaim(name) + } catch (ex: JWTDecodeException) { + emptyList() + } + } + + companion object { + private const val AUTHORITIES_CLAIM = "authorities" + const val ROLES_CLAIM = "roles" + const val SCOPE_CLAIM = "scope" + const val SOURCES_CLAIM = "sources" + const val GRANT_TYPE_CLAIM = "grant_type" + const val CLIENT_ID_CLAIM = "client_id" + const val USER_NAME_CLAIM = "user_name" + + private fun DecodedJWT.listClaim(name: String): List = getClaim(name) + .asList(String::class.java) + ?.filterTo(ArrayList()) { s: String? -> !s.isNullOrBlank() } + ?: emptyList() + + private fun DecodedJWT.stringClaim(name: String) = getClaim(name) + .asString() + ?: "" + + private fun String.parseScopes() = split(' ') + .filterTo(mutableSetOf()) { it.isNotBlank() } + + private fun DecodedJWT.parseAuthorities(): Sequence = listClaim( + AUTHORITIES_CLAIM + ) + .asSequence() + .mapNotNull { RoleAuthority.valueOfAuthorityOrNull(it) } + .filter { it.scope == RoleAuthority.Scope.GLOBAL } + .map { AuthorityReference(it) } + + private fun DecodedJWT.parseRoles(): Sequence = listClaim(ROLES_CLAIM) + .asSequence() + .mapNotNull { input -> + val v = input.split(':') + try { + if (v.size == 1 || v[1].isEmpty()) { + AuthorityReference(v[0]) + } else { + AuthorityReference(v[1], v[0]) + } + } catch (ex: IllegalArgumentException) { + null + } + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt new file mode 100644 index 000000000..425679599 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt @@ -0,0 +1,35 @@ +package org.radarbase.auth.jwt + +import com.auth0.jwt.exceptions.JWTVerificationException +import com.auth0.jwt.exceptions.SignatureVerificationException +import com.auth0.jwt.interfaces.JWTVerifier +import org.radarbase.auth.authentication.TokenVerifier +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.token.RadarToken +import org.slf4j.LoggerFactory + +class JwtTokenVerifier( + private val algorithm: String, + private val verifier: JWTVerifier, +) : TokenVerifier { + override fun verify(token: String): RadarToken = try { + val jwt = verifier.verify(token) + + // Do not print full token with signature to avoid exposing valid token in logs. + logger.debug("Verified JWT header {} and payload {}", jwt.header, jwt.payload) + + JwtRadarToken(jwt) + } catch (ex: Throwable) { + when (ex) { + is SignatureVerificationException -> logger.debug("Client presented a token with an incorrect signature.") + is JWTVerificationException -> logger.debug("Verifier {} did not accept token: {}", verifier.javaClass, ex.message) + } + throw ex + } + + override fun toString(): String = "JwtTokenVerifier(algorithm=$algorithm)" + + companion object { + private val logger = LoggerFactory.getLogger(JwtTokenVerifier::class.java) + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKey.java b/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKey.java deleted file mode 100644 index edee429e5..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKey.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.radarbase.auth.security.jwk; - -import java.util.Objects; - -/** - * Represents the JavaWebKey for token verification. - */ -public class JavaWebKey { - - private String alg; - - private String kty; - - private String value; - - - public JavaWebKey alg(String alg) { - this.alg = alg; - return this; - } - - public JavaWebKey value(String value) { - this.value = value; - return this; - } - - public JavaWebKey kty(String kty) { - this.kty = kty; - return this; - } - - public String getAlg() { - return alg; - } - - public String getValue() { - return value; - } - - public String getKty() { - return kty; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - JavaWebKey that = (JavaWebKey) o; - return Objects.equals(alg, that.alg) - && Objects.equals(kty, that.kty) - && Objects.equals(value, that.value); - } - - @Override - public int hashCode() { - - return Objects.hash(alg, kty, value); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKeySet.java b/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKeySet.java deleted file mode 100644 index 6ca8621aa..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/security/jwk/JavaWebKeySet.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.radarbase.auth.security.jwk; - -import java.util.ArrayList; -import java.util.List; - -public class JavaWebKeySet { - private List keys = new ArrayList<>(); - - public JavaWebKeySet() { - // JSON constructor - } - - public JavaWebKeySet(List keys) { - this.keys = keys; - } - - public List getKeys() { - return keys; - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.java b/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.java deleted file mode 100644 index e2122b712..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.radarbase.auth.token; - -import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.authorization.RoleAuthority; - -import java.util.Objects; -import java.util.function.Predicate; -import java.util.stream.Stream; - -/** - * Partial implementation of {@link RadarToken}, providing a default implementation for the three - * permission checks. - */ -public abstract class AbstractRadarToken implements RadarToken { - protected static final String CLIENT_CREDENTIALS = "client_credentials"; - - @Override - public boolean hasAuthority(RoleAuthority authority) { - return isClientCredentials() - || getRoleAuthorities().stream().anyMatch(authority::equals); - } - - @Override - public boolean hasPermission(Permission permission) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasAuthorityForPermission(permission)); - - } - - @Override - public boolean hasGlobalPermission(Permission permission) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasGlobalAuthorityForPermission(permission)); - } - - @Override - public boolean hasPermissionOnOrganization(Permission permission, String organization) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasAuthorityForOrganization(permission, organization)); - } - - @Override - public boolean hasPermissionOnOrganizationAndProject(Permission permission, String organization, - String projectName) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasAuthorityForOrganizationAndProject( - permission, organization, projectName)); - } - - @Override - public boolean hasPermissionOnProject(Permission permission, String projectName) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasAuthorityForProject( - permission, projectName)); - } - - @Override - public boolean hasPermissionOnSubject(Permission permission, String projectName, - String subjectName) { - return hasScope(permission.scope()) - && (isClientCredentials() || hasAuthorityForSubject(permission, projectName, - subjectName)); - } - - @Override - public boolean hasPermissionOnSource(Permission permission, String projectName, - String subjectName, String sourceId) { - return hasScope(permission.scope()) - && (isClientCredentials() - || hasAuthorityForSource(permission, projectName, subjectName, sourceId)); - } - - protected boolean hasScope(String scope) { - return getScopes().contains(scope); - } - - @Override - public boolean isClientCredentials() { - return CLIENT_CREDENTIALS.equals(getGrantType()); - } - - /** - * Check all authorities in this token, project and non-project specific, for the given - * permission. - * @param permission the permission - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForPermission(Permission permission) { - return Stream.concat(getRoles().stream().map(AuthorityReference::getRole), - getRoleAuthorities().stream()) - .anyMatch(permission::isRoleAllowed); - } - - /** - * Check authorities in this token linked to the given project, or not linked to any project - * (such as {@code SYS_ADMIN}), for the given permission. - * @param permission the permission - * @param organization the organization name - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForOrganization(Permission permission, String organization) { - if (hasGlobalAuthorityForPermission(permission)) { - return true; - } - if (organization == null) { - return false; - } - return getReferentsWithPermission(RoleAuthority.Scope.ORGANIZATION, permission) - .anyMatch(organization::equals); - } - - /** - * Check authorities in this token linked to the given project, or not linked to any project - * (such as {@code SYS_ADMIN}), for the given permission. - * @param permission the permission - * @param organization the organization name - * @param projectName the project name - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForOrganizationAndProject(Permission permission, - String organization, String projectName) { - return hasAuthorityForOrganization(permission, organization) - || hasAuthorityForProject(permission, projectName); - } - - /** - * Check whether roles from this token give authority to a given subject. By providing an - * additional personal role condition, if the authority is personal (e.g. subject-specific), - * the predicate for that subject will be evaluated. - * - * @param permission permission to check. - * @param projectName project name. - * @param personalRoleCondition additional condition (possibly null) when a role is - * only applies to the person. - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForProject( - Permission permission, - String projectName, - Predicate personalRoleCondition) { - if (hasGlobalAuthorityForPermission(permission)) { - return true; - } - if (projectName == null) { - return false; - } - return getAuthorityReferencesWithPermission(RoleAuthority.Scope.PROJECT, permission) - .anyMatch(r -> projectName.equals(r.getReferent()) - && (personalRoleCondition == null - || !r.getRole().isPersonal() - || personalRoleCondition.test(r.getRole()))); - } - - /** - * Check authorities in this token linked to the given project, or not linked to any project - * (such as {@code SYS_ADMIN}), for the given permission. - * @param permission the permission - * @param projectName the project name - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForProject(Permission permission, String projectName) { - return hasAuthorityForProject(permission, projectName, - p -> permission.getOperation() == Permission.Operation.READ); - } - - /** - * Check authorities in this token linked to the given project, or not linked to any project - * (such as {@code SYS_ADMIN}), for the given permission on the given subject. - * @param permission the permission - * @param projectName the project name - * @param subjectName the subject name - * @return {@code true} if any authority contains the permission, {@code false} otherwise - */ - protected boolean hasAuthorityForSubject(Permission permission, String projectName, - String subjectName) { - return hasAuthorityForProject(permission, projectName, role -> - getUsername().equals(subjectName)); - } - - protected boolean hasAuthorityForSource(Permission permission, String projectName, - String subjectName, String sourceId) { - return hasAuthorityForProject(permission, projectName, role -> - getUsername().equals(subjectName) && getSources().contains(sourceId)); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - if (other == null || other.getClass() != getClass()) { - return false; - } - - return Objects.equals(getToken(), ((AbstractRadarToken)other).getToken()); - } - - @Override - public int hashCode() { - return Objects.hashCode(getToken()); - } - - @Override - public String toString() { - return getClass().getSimpleName() + "{" - + "scopes=" + getScopes() - + ", username='" + getUsername() + '\'' - + ", subject='" + getSubject() + '\'' - + ", roles=" + getRoles() - + ", sources=" + getSources() - + ", authorities=" + getAuthorities() - + ", grantType='" + getGrantType() + '\'' - + ", audience=" + getAudience() - + ", issuer='" + getIssuer() + '\'' - + ", issuedAt=" + getIssuedAt() - + ", expiresAt=" + getExpiresAt() - + ", type='" + getType() + '\'' - + '}'; - } - -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt new file mode 100644 index 000000000..5dc1df8a6 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt @@ -0,0 +1,55 @@ +package org.radarbase.auth.token + +/** + * Partial implementation of [RadarToken], providing a default implementation for the three + * permission checks. + */ +abstract class AbstractRadarToken : RadarToken { + override val isClientCredentials: Boolean + get() = CLIENT_CREDENTIALS == grantType + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other?.javaClass != javaClass) return false + + other as AbstractRadarToken + return token == other.token + } + + override fun hashCode(): Int = token.hashCode() + + override fun toString(): String = buildString { + append("AbstractRadarToken{scopes=") + append(scopes) + append(", username='") + append(username) + append('\'') + append(", subject='") + append(subject) + append('\'') + append(", roles=") + append(roles) + append(", sources=") + append(sources) + append(", grantType='") + append(grantType) + append('\'') + append(", audience=") + append(audience) + append(", issuer='") + append(issuer) + append('\'') + append(", issuedAt=") + append(issuedAt) + append(", expiresAt=") + append(expiresAt) + append(", type='") + append(type) + append('\'') + append('}') + } + + companion object { + const val CLIENT_CREDENTIALS = "client_credentials" + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.java b/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.java deleted file mode 100644 index 218b78366..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2021. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.auth.token; - -import org.radarbase.auth.authorization.RoleAuthority; - -import java.io.Serializable; -import java.util.Objects; - -import static java.util.Objects.requireNonNull; - -public class AuthorityReference implements Serializable { - private final String authority; - private final String referent; - private final RoleAuthority role; - - public AuthorityReference(String authority) { - this(authority, null); - } - - public AuthorityReference(RoleAuthority role) { - this(role, null); - } - - /** - * Authority reference with given role and the object it refers to. - * @param role user role. - * @param referent reference. - */ - public AuthorityReference(RoleAuthority role, String referent) { - this.role = requireNonNull(role); - this.authority = role.authority(); - this.referent = referent; - } - - /** - * Authority reference with given authority and the object it refers to. - * @param authority user authority. - * @param referent reference. - */ - public AuthorityReference(String authority, String referent) { - this.authority = requireNonNull(authority); - this.role = RoleAuthority.valueOfAuthorityOrNull(authority); - this.referent = referent; - } - - public RoleAuthority getRole() { - return role; - } - - public String getReferent() { - return referent; - } - - public String getAuthority() { - return authority; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - AuthorityReference that = (AuthorityReference) o; - - return Objects.equals(referent, that.referent) && authority.equals(that.authority); - } - - @Override - public int hashCode() { - int result = referent != null ? referent.hashCode() : 0; - result = 31 * result + authority.hashCode(); - return result; - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt b/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt new file mode 100644 index 000000000..4d9ad9b8b --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021. The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * See the file LICENSE in the root of this repository. + */ +package org.radarbase.auth.token + +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.authorization.RoleAuthority.Companion.valueOfAuthority +import java.io.Serializable + +data class AuthorityReference( + val role: RoleAuthority, + val authority: String, + val referent: String?, +): Serializable { + init { + require(role.scope == RoleAuthority.Scope.GLOBAL || referent != null) { "Non-global authority references require a referent entity" } + } + + /** + * Authority reference with given role and the object it refers to. + * @param role user role. + * @param referent reference. + */ + @JvmOverloads + constructor(role: RoleAuthority, referent: String? = null) : this(role, role.authority, referent) + + /** + * Authority reference with given authority and the object it refers to. + * @param authority user authority. + * @param referent reference. + */ + @JvmOverloads + constructor(authority: String, referent: String? = null) : this(valueOfAuthority(authority), authority, referent) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/JwtRadarToken.java b/radar-auth/src/main/java/org/radarbase/auth/token/JwtRadarToken.java deleted file mode 100644 index 0ff56ff23..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/JwtRadarToken.java +++ /dev/null @@ -1,190 +0,0 @@ -package org.radarbase.auth.token; - -import static java.util.Objects.requireNonNullElseGet; -import static java.util.stream.Collectors.toUnmodifiableSet; - -import com.auth0.jwt.exceptions.JWTDecodeException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import org.radarbase.auth.authorization.RoleAuthority; - -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -/** - * Implementation of {@link RadarToken} based on JWT tokens. - */ -public class JwtRadarToken extends AbstractRadarToken { - private static final Pattern ROLE_SEPARATOR_PATTERN = Pattern.compile(":"); - - public static final String AUTHORITIES_CLAIM = "authorities"; - public static final String ROLES_CLAIM = "roles"; - public static final String SCOPE_CLAIM = "scope"; - public static final String SOURCES_CLAIM = "sources"; - public static final String GRANT_TYPE_CLAIM = "grant_type"; - public static final String CLIENT_ID_CLAIM = "client_id"; - public static final String USER_NAME_CLAIM = "user_name"; - - private final Set roles; - private final List authorities; - private final List scopes; - private final List sources; - private final String grantType; - private final String subject; - private final Date issuedAt; - private final Date expiresAt; - private final List audience; - private final String token; - private final String issuer; - private final String type; - private final String clientId; - private final DecodedJWT jwt; - private final String username; - - /** - * Initialize this {@code JwtRadarToken} based on the {@link DecodedJWT}. All relevant - * information will be parsed at construction time and no reference to the {@link DecodedJWT} - * is kept. Therefore, modifying the passed in {@link DecodedJWT} after this has been - * constructed will not update this object. - * @param jwt the JWT token to use to initialize this object - */ - public JwtRadarToken(DecodedJWT jwt) { - this.jwt = jwt; - authorities = emptyIfNull(jwt.getClaim(AUTHORITIES_CLAIM).asList(String.class)); - roles = Stream.concat( - authorities.stream() - .map(RoleAuthority::valueOfAuthorityOrNull) - .filter(r -> r != null && r.scope() == RoleAuthority.Scope.GLOBAL) - .map(AuthorityReference::new), - parseRoles(jwt)) - .collect(toUnmodifiableSet()); - - Claim scopeClaim = jwt.getClaim(SCOPE_CLAIM); - String scopeClaimString = scopeClaim.asString(); - - if (scopeClaimString != null) { - scopes = Arrays.asList(scopeClaimString.split(" ")); - } else { - List scopeClaimList = scopeClaim.asList(String.class); - scopes = requireNonNullElseGet(scopeClaimList, Collections::emptyList); - } - - sources = emptyIfNull(jwt.getClaim(SOURCES_CLAIM).asList(String.class)); - grantType = emptyIfNull(jwt.getClaim(GRANT_TYPE_CLAIM).asString()); - subject = emptyIfNull(jwt.getSubject()); - username = emptyIfNull(jwt.getClaim(USER_NAME_CLAIM).asString()); - issuedAt = jwt.getIssuedAt(); - expiresAt = jwt.getExpiresAt(); - audience = emptyIfNull(jwt.getAudience()); - token = emptyIfNull(jwt.getToken()); - issuer = emptyIfNull(jwt.getIssuer()); - type = emptyIfNull(jwt.getType()); - clientId = jwt.getClaim(CLIENT_ID_CLAIM).asString(); - } - - @Override - public Set getRoles() { - return roles; - } - - @Override - public List getAuthorities() { - return authorities; - } - - @Override - public List getScopes() { - return scopes; - } - - @Override - public List getSources() { - return sources; - } - - @Override - public String getGrantType() { - return grantType; - } - - @Override - public String getSubject() { - return subject; - } - - @Override - public String getUsername() { - return username; - } - - @Override - public Date getIssuedAt() { - return issuedAt; - } - - @Override - public Date getExpiresAt() { - return expiresAt; - } - - @Override - public List getAudience() { - return audience; - } - - @Override - public String getToken() { - return token; - } - - @Override - public String getIssuer() { - return issuer; - } - - @Override - public String getType() { - return type; - } - - @Override - public String getClientId() { - return clientId; - } - - @Override - public String getClaimString(String name) { - return jwt.getClaim(name).asString(); - } - - @Override - public List getClaimList(String name) { - try { - return emptyIfNull(jwt.getClaim(name).asList(String.class)); - } catch (JWTDecodeException ex) { - return List.of(); - } - } - - private Stream parseRoles(DecodedJWT jwt) { - return emptyIfNull(jwt.getClaim(ROLES_CLAIM).asList(String.class)).stream() - .filter(s -> s != null && !s.isBlank()) - .map(ROLE_SEPARATOR_PATTERN::split) - .map(v -> v.length == 1 || v[1].isEmpty() - ? new AuthorityReference(v[0]) - : new AuthorityReference(v[1], v[0])); - } - - private static String emptyIfNull(String string) { - return string != null ? string : ""; - } - - private static List emptyIfNull(List list) { - return requireNonNullElseGet(list, Collections::emptyList); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.java b/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.java deleted file mode 100644 index 96f0858b5..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.java +++ /dev/null @@ -1,252 +0,0 @@ -package org.radarbase.auth.token; - -import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.authorization.RoleAuthority; - -import java.util.Date; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Stream; - -import static org.radarbase.auth.authorization.RoleAuthority.toEnumSet; - -/** - * Created by dverbeec on 10/01/2018. - */ -public interface RadarToken { - /** - * Get all roles defined in this token. - * @return non-null set describing the roles defined in this token. - */ - Set getRoles(); - - default Stream getAuthorityReferencesWithPermission( - RoleAuthority.Scope scope, Permission permission) { - return getRoles().stream() - .filter(r -> r.getRole().scope() == scope && permission.isRoleAllowed(r.getRole())); - } - - /** - * Get the referents (i.e., organization or project name) in a given scope that matches a - * permission. - * @param scope scope of the referents - * @param permission permission that should be authorized within the scope - * @return referents names - */ - default Stream getReferentsWithPermission(RoleAuthority.Scope scope, - Permission permission) { - return getAuthorityReferencesWithPermission(scope, permission) - .map(AuthorityReference::getReferent) - .filter(Objects::nonNull); - } - - /** - * Check if any non-project related authority has the given permission. Currently the only - * non-project authority is {@code SYS_ADMIN}, so we only check for that. - * @param permission the permission - * @return {@code true} if any non-project related authority has the permission, {@code false} - * otherwise - */ - default boolean hasGlobalAuthorityForPermission(Permission permission) { - return Stream.concat( - getAuthorities().stream().map(RoleAuthority::valueOfAuthorityOrNull), - getRoles().stream().map(AuthorityReference::getRole)) - .anyMatch(r -> r != null - && r.scope() == RoleAuthority.Scope.GLOBAL - && permission.isRoleAllowed(r)); - } - - /** - * Get a list of non-project related authorities. - * @return non-null list of authority names - */ - List getAuthorities(); - - /** - * Get a list of non-project related authorities. - * @return non-null list of authority names - */ - default Set getRoleAuthorities() { - return getAuthorities().stream() - .map(RoleAuthority::valueOfAuthorityOrNull) - .filter(Objects::nonNull) - .collect(toEnumSet(RoleAuthority.class)); - } - - /** - * Get a list of scopes assigned to this token. - * @return non-null list of scope names - */ - List getScopes(); - - /** - * Get a list of source names associated with this token. - * @return non-null list of source names - */ - List getSources(); - - /** - * Get this token's OAuth2 grant type. - * @return non-null grant type - */ - String getGrantType(); - - /** - * Get the token subject. - * @return non-null subject - */ - String getSubject(); - - /** - * Get the token username. - */ - String getUsername(); - - /** - * Get the date this token was issued. - * @return date this token was issued or null - */ - Date getIssuedAt(); - - /** - * Get the date this token expires. - * @return date this token expires or null - */ - Date getExpiresAt(); - - /** - * Get the audience of the token. - * @return non-null list of resources that are allowed to accept the token - */ - List getAudience(); - - /** - * Get a string representation of this token. - * @return non-null string representation of this token - */ - String getToken(); - - /** - * Get the issuer. - * @return non-null issuer - */ - String getIssuer(); - - /** - * Get the token type. - * @return non-null token type. - */ - String getType(); - - /** - * Client that the token is associated to. - * @return client ID if set or null if unknown. - */ - String getClientId(); - - /** - * Get a token claim by name. - * @param name claim name. - * @return a claim value or null if none was found or the type was not a string. - */ - String getClaimString(String name); - - /** - * Get a token claim list by name. - * @param name claim name. - * @return a claim list of values or null if none was found or the type was not a string. - */ - List getClaimList(String name); - - /** - * Check if this token gives the given permission, not taking into account project affiliations. - * - *

This token must have the authority in its set of authorities. If it's a - * client credentials token, this is the only requirement, as a client credentials token is - * linked to an OAuth client and not to a user in the system. - * @param authority The permission to check - * @return {@code true} if this token has the permission, {@code false} otherwise - */ - boolean hasAuthority(RoleAuthority authority); - - /** - * Check if this token gives the given permission, not taking into account project affiliations. - * - *

This token must have the permission in its set of scopes. If it's a - * client credentials token, this is the only requirement, as a client credentials token is - * linked to an OAuth client and not to a user in the system. If it's not a client - * credentials token, this also checks to see if the user has a role with the specified - * permission.

- * @param permission The permission to check - * @return {@code true} if this token has the permission, {@code false} otherwise - */ - boolean hasPermission(Permission permission); - - /** - * Check if this token gives the given permission from a global scope. - * - *

This token must have the permission in its set of scopes. If it's a - * client credentials token, this is the only requirement, as a client credentials token is - * linked to an OAuth client and not to a user in the system. If it's not a client - * credentials token, this also checks to see if the user has a global role with the specified - * permission.

- * @param permission The permission to check - * @return {@code true} if this token has the permission, {@code false} otherwise - */ - boolean hasGlobalPermission(Permission permission); - - /** - * Check if this token gives a permission in a specific organization. - * @param permission the permission - * @param organization the organization name - * @return true if this token has the permission in the project, false otherwise - */ - boolean hasPermissionOnOrganization(Permission permission, String organization); - - /** - * Check if this token gives a permission in a specific project in a given organization. - * @param permission the permission - * @param organization the organization name - * @param projectName the project name - * @return true if this token has the permission in the project, false otherwise - */ - boolean hasPermissionOnOrganizationAndProject(Permission permission, String organization, - String projectName); - - /** - * Check if this token gives a permission in a specific project. - * @param permission the permission - * @param projectName the project name - * @return true if this token has the permission in the project, false otherwise - */ - boolean hasPermissionOnProject(Permission permission, String projectName); - - /** - * Check if this token gives a permission on a subject in a given project. - * @param permission the permission - * @param projectName the project name - * @param subjectName the subject name - * @return true if this token ahs the permission for the subject in the given project, false - * otherwise - */ - boolean hasPermissionOnSubject(Permission permission, String projectName, String subjectName); - - /** - * Check if this token gives a permission on a given source. - * @param permission the permission - * @param projectName the project name - * @param subjectName the subject name - * @param sourceId the source ID - * @return true if this token gives permission for the source, false otherwise - */ - boolean hasPermissionOnSource(Permission permission, String projectName, String subjectName, - String sourceId); - - /** - * Whether the current credentials were obtained with a OAuth 2.0 client credentials flow. - * - * @return true if the client credentials flow was certainly used, false otherwise. - */ - boolean isClientCredentials(); -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt new file mode 100644 index 000000000..4f93e99e8 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt @@ -0,0 +1,106 @@ +package org.radarbase.auth.token + +import java.util.* + +/** + * Created by dverbeec on 10/01/2018. + */ +interface RadarToken { + /** + * Get all roles defined in this token. + * @return non-null set describing the roles defined in this token. + */ + val roles: Set + + /** + * Get a list of scopes assigned to this token. + * @return non-null list of scope names + */ + val scopes: Set + + /** + * Get a list of source names associated with this token. + * @return non-null list of source names + */ + val sources: List + + /** + * Get this token's OAuth2 grant type. + * @return non-null grant type + */ + val grantType: String + + /** + * Get the token subject. + * @return non-null subject + */ + val subject: String + + /** + * Get the token username. + */ + val username: String + + /** + * Get the date this token was issued. + * @return date this token was issued or null + */ + val issuedAt: Date + + /** + * Get the date this token expires. + * @return date this token expires or null + */ + val expiresAt: Date + + /** + * Get the audience of the token. + * @return non-null list of resources that are allowed to accept the token + */ + val audience: List + + /** + * Get a string representation of this token. + * @return non-null string representation of this token + */ + val token: String + + /** + * Get the issuer. + * @return non-null issuer + */ + val issuer: String + + /** + * Get the token type. + * @return non-null token type. + */ + val type: String + + /** + * Client that the token is associated to. + * @return client ID if set or null if unknown. + */ + val clientId: String + + /** + * Get a token claim by name. + * @param name claim name. + * @return a claim value or null if none was found or the type was not a string. + */ + fun getClaimString(name: String): String? + + /** + * Get a token claim list by name. + * @param name claim name. + * @return a claim list of values or null if none was found or the type was not a string. + */ + fun getClaimList(name: String): List + + /** + * Whether the current credentials were obtained with a OAuth 2.0 client credentials flow. + * + * @return true if the client credentials flow was certainly used, false otherwise. + */ + val isClientCredentials: Boolean +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/validation/AbstractTokenValidationAlgorithm.java b/radar-auth/src/main/java/org/radarbase/auth/token/validation/AbstractTokenValidationAlgorithm.java deleted file mode 100644 index 927d34f24..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/validation/AbstractTokenValidationAlgorithm.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.radarbase.auth.token.validation; - -import org.radarbase.auth.exception.ConfigurationException; - -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; - -public abstract class AbstractTokenValidationAlgorithm implements TokenValidationAlgorithm { - /** - * The key factory type for keys that this algorithm can parse. - * @return the key factory type - */ - protected abstract String getKeyFactoryType(); - - /** - * Parse a public key in PEM format. - * @param publicKey the public key to parse - * @return a PublicKey object representing the supplied public key - */ - protected PublicKey parseKey(String publicKey) { - String trimmedKey = publicKey.replaceAll("-----BEGIN ([A-Z]+ )?PUBLIC KEY-----", ""); - trimmedKey = trimmedKey.replaceAll("-----END ([A-Z]+ )?PUBLIC KEY-----", ""); - trimmedKey = trimmedKey.trim(); - - try { - byte[] decodedPublicKey = Base64.getDecoder().decode(trimmedKey); - X509EncodedKeySpec spec = new X509EncodedKeySpec(decodedPublicKey); - KeyFactory kf = KeyFactory.getInstance(getKeyFactoryType()); - return kf.generatePublic(spec); - } catch (Exception ex) { - throw new ConfigurationException(ex); - } - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/validation/ECTokenValidationAlgorithm.java b/radar-auth/src/main/java/org/radarbase/auth/token/validation/ECTokenValidationAlgorithm.java deleted file mode 100644 index 6866c092c..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/validation/ECTokenValidationAlgorithm.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.radarbase.auth.token.validation; - -import com.auth0.jwt.algorithms.Algorithm; - -import java.security.interfaces.ECPublicKey; - -public class ECTokenValidationAlgorithm extends AbstractTokenValidationAlgorithm { - - @Override - public String getJwtAlgorithm() { - return "SHA256withECDSA"; - } - - @Override - public String getKeyHeader() { - return "-----BEGIN EC PUBLIC KEY-----"; - } - - @Override - public Algorithm getAlgorithm(String publicKey) { - return Algorithm.ECDSA256((ECPublicKey) parseKey(publicKey), null); - } - - @Override - protected String getKeyFactoryType() { - return "EC"; - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/validation/RSATokenValidationAlgorithm.java b/radar-auth/src/main/java/org/radarbase/auth/token/validation/RSATokenValidationAlgorithm.java deleted file mode 100644 index 3f688d410..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/validation/RSATokenValidationAlgorithm.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.radarbase.auth.token.validation; - -import com.auth0.jwt.algorithms.Algorithm; - -import java.security.interfaces.RSAPublicKey; - -public class RSATokenValidationAlgorithm extends AbstractTokenValidationAlgorithm { - @Override - protected String getKeyFactoryType() { - return "RSA"; - } - - @Override - public String getJwtAlgorithm() { - return "SHA256withRSA"; - } - - @Override - public String getKeyHeader() { - return "-----BEGIN PUBLIC KEY-----"; - } - - @Override - public Algorithm getAlgorithm(String publicKey) { - return Algorithm.RSA256((RSAPublicKey) parseKey(publicKey), null); - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/validation/TokenValidationAlgorithm.java b/radar-auth/src/main/java/org/radarbase/auth/token/validation/TokenValidationAlgorithm.java deleted file mode 100644 index dd881b5f1..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/validation/TokenValidationAlgorithm.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.radarbase.auth.token.validation; - -import com.auth0.jwt.algorithms.Algorithm; - -public interface TokenValidationAlgorithm { - /** - * Get the algorithm description as it will be reported by the server public key endpoint - * (e.g. "SHA256withRSA" or "SHA256withEC"). - * @return the algorithm description - */ - String getJwtAlgorithm(); - - /** - * Get the header for a PEM encoded key that this algorithm can parse. - * - * @return the header for a PEM encoded key that this algorithm can parse - */ - String getKeyHeader(); - - /** - * Build a verification algorithm based on the supplied public key. - * @param publicKey the public key in PEM format - * @return the verification algorithm - */ - Algorithm getAlgorithm(String publicKey); -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt b/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt new file mode 100644 index 000000000..45cef3e19 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt @@ -0,0 +1,122 @@ +package org.radarbase.auth.util + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +/** + * Caches a value with full support for coroutines. + * Only one coroutine context will compute the value at a time, other coroutine contexts will wait + * for it to finish. + */ +class CachedValue( + private val minAge: Duration = Duration.ofMinutes(1), + private val maxAge: Duration = Duration.ofHours(3), + private val compute: suspend () -> T, +) { + init { + require(minAge > Duration.ZERO) { "Cache fetch duration $minAge must be positive" } + require(maxAge >= minAge) { "Cache maximum age $maxAge must be at least fetch timeout $minAge" } + } + + private val cache = AtomicReference>>() + + /** + * Get cached value. If the cache is expired, fetch it again. The first coroutine context + * that reaches this method will call [compute], others coroutine contexts will use the value + * computed by the first. + */ + suspend fun get(refresh: Boolean = false): CacheResult { + val deferred = raceForDeferred() + + val result: CacheContents + + if (deferred is CacheMiss) { + result = try { + CacheValue(compute()) + } catch (ex: Throwable) { + CacheError(ex) + } + deferred.value.complete(result) + } else { + result = deferred.value.await() + // If the result is expired, refetch. + if (result.isExpired(refresh)) { + // Either no new coroutine context had updated the cache value, then update it to + // null. Otherwise, another suspend context is active and get() will await the + // result from that context + cache.compareAndSet(deferred.value, null) + return get(refresh = false) + } + } + + return when (result) { + is CacheValue -> if (deferred is CacheMiss) CacheMiss(result.value) else CacheHit(result.value) + is CacheError -> throw result.exception + } + } + + /** + * Race for the first suspend context to create a CompletableDeferred object. All other contexts + * will use that context to read their values. + * + * @return a pair of a CompletableDeferred value and a boolean, if true this context is the + * winner, if false this should use the deferred to read its value. + */ + private fun raceForDeferred(): CacheResult>> { + var result: CacheResult>> + + do { + val previousDeferred = cache.get() + result = if (previousDeferred == null) { + CacheMiss(CompletableDeferred()) + } else { + CacheHit(previousDeferred) + } + } while (!cache.compareAndSet(previousDeferred, result.value)) + + return result + } + + private fun CacheContents<*>.isExpired(refresh: Boolean): Boolean = when { + this is CacheError && exception is CancellationException -> true + refresh || this is CacheError -> isExpired(minAge) + else -> isExpired(maxAge) + } + + fun clear() { + cache.set(null) + } + + private sealed class CacheContents { + val time: Instant = Instant.now() + + @Volatile + private var isExpired = false + + fun isExpired(age: Duration): Boolean = when { + isExpired -> true + Instant.now() > time + age -> { + isExpired = true + true + } + else -> false + } + } + + private class CacheError( + val exception: Throwable, + ): CacheContents() + + private class CacheValue( + val value: T, + ): CacheContents() + + sealed interface CacheResult { + val value: T + } + data class CacheHit(override val value: T): CacheResult + data class CacheMiss(override val value: T): CacheResult +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt b/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt new file mode 100644 index 000000000..210a1210a --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt @@ -0,0 +1,24 @@ +package org.radarbase.auth.util + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.consume + +internal suspend fun Iterable.forkJoin(convert: suspend (T) -> R): List = coroutineScope { + map { t -> async { convert(t) } } + .awaitAll() +} + +internal suspend fun consumeFirst(producer: suspend CoroutineScope.(SendChannel) -> Unit): T = coroutineScope { + val channel = Channel() + + val producerJob = launch { + producer(channel) + channel.close() + } + + val result = channel.consume { receive() } + producerJob.cancel() + result +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java b/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java index b97e0ad14..eb19cf3ac 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java +++ b/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java @@ -1,54 +1,60 @@ package org.radarbase.auth.authentication; -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.File; -import java.net.URISyntaxException; - import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.contrib.java.lang.system.EnvironmentVariables; -import org.radarbase.auth.config.TokenVerifierPublicKeyConfig; import org.radarbase.auth.exception.TokenValidationException; +import org.radarbase.auth.jwks.JwkAlgorithmParser; +import org.radarbase.auth.jwks.JwksTokenVerifierLoader; +import org.radarbase.auth.jwks.RSAPEMCertificateParser; import org.radarbase.auth.util.TokenTestUtils; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.radarbase.auth.util.TokenTestUtils.WIREMOCK_PORT; + /** * Created by dverbeec on 24/04/2017. */ class TokenValidatorTest { - private final EnvironmentVariables environmentVariables = new EnvironmentVariables(); private static WireMockServer wireMockServer; private TokenValidator validator; @BeforeAll public static void loadToken() throws Exception { wireMockServer = new WireMockServer(new WireMockConfiguration() - .port(TokenTestUtils.WIREMOCK_PORT)); + .port(WIREMOCK_PORT)); wireMockServer.start(); } /** * Set up a stub public key endpoint and initialize a TokenValidator object. * - * @throws Exception if anything went wrong during setup */ @BeforeEach - public void setUp() throws Exception { + public void setUp() { wireMockServer.stubFor(get(urlEqualTo(TokenTestUtils.PUBLIC_KEY)).willReturn(aResponse() .withStatus(200) .withHeader("Content-type", TokenTestUtils.APPLICATION_JSON) .withBody(TokenTestUtils.PUBLIC_KEY_BODY))); - validator = new TokenValidator(); + + var algorithmParser = new JwkAlgorithmParser(List.of(new RSAPEMCertificateParser())); + var verifierLoader = new JwksTokenVerifierLoader( + "http://localhost:" + WIREMOCK_PORT + TokenTestUtils.PUBLIC_KEY, + "unit_test", + algorithmParser + ); + validator = new TokenValidator(List.of(verifierLoader)); } @AfterEach @@ -63,35 +69,24 @@ public static void tearDown() { @Test void testValidToken() { - validator.validateAccessToken(TokenTestUtils.VALID_RSA_TOKEN); + validator.authenticateBlocking(TokenTestUtils.VALID_RSA_TOKEN); } @Test void testIncorrectAudienceToken() { assertThrows(TokenValidationException.class, - () -> validator.validateAccessToken(TokenTestUtils.INCORRECT_AUDIENCE_TOKEN)); + () -> validator.authenticateBlocking(TokenTestUtils.INCORRECT_AUDIENCE_TOKEN)); } @Test void testExpiredToken() { assertThrows(TokenValidationException.class, - () -> validator.validateAccessToken(TokenTestUtils.EXPIRED_TOKEN)); + () -> validator.authenticateBlocking(TokenTestUtils.EXPIRED_TOKEN)); } @Test void testIncorrectAlgorithmToken() { assertThrows(TokenValidationException.class, - () -> validator.validateAccessToken(TokenTestUtils.INCORRECT_ALGORITHM_TOKEN)); - } - - @Test - void testPublicKeyFromConfigFile() throws URISyntaxException { - ClassLoader loader = Thread.currentThread().getContextClassLoader(); - File configFile = new File(loader.getResource("radar-is-2.yml").toURI()); - environmentVariables - .set(TokenVerifierPublicKeyConfig.LOCATION_ENV, configFile.getAbsolutePath()); - // reinitialize TokenValidator to pick up new config - validator = new TokenValidator(); - validator.validateAccessToken(TokenTestUtils.VALID_RSA_TOKEN); + () -> validator.authenticateBlocking(TokenTestUtils.INCORRECT_ALGORITHM_TOKEN)); } } diff --git a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java index aa2fbcd99..311685383 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java +++ b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java @@ -1,9 +1,11 @@ package org.radarbase.auth.authorization; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.radarbase.auth.authorization.Permission.Entity; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.JwtRadarToken; +import org.radarbase.auth.jwt.JwtRadarToken; +import org.radarbase.auth.token.AbstractRadarTokenTest; import org.radarbase.auth.token.RadarToken; import org.radarbase.auth.util.TokenTestUtils; @@ -13,62 +15,70 @@ import java.util.Map.Entry; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; -import static org.hamcrest.CoreMatchers.instanceOf; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Created by dverbeec on 25/09/2017. */ class RadarAuthorizationTest { + private AuthorizationOracle oracle; + + @BeforeEach + public void setUp() { + this.oracle = new AuthorizationOracle( + new AbstractRadarTokenTest.MockEntityRelationService()); + } + @Test void testCheckPermissionOnProject() throws NotAuthorizedException { String project = "PROJECT1"; // let's get all permissions a project admin has - Set permissions = Permissions.getPermissionMatrix().entrySet().stream() + Set permissions = AuthorizationOracle.Permissions.getPermissionMatrix() + .entrySet().stream() .filter(e -> e.getValue().contains(RoleAuthority.PROJECT_ADMIN)) .map(Entry::getKey) .collect(Collectors.toSet()); RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); for (Permission p : permissions) { - RadarAuthorization.checkPermissionOnProject(token, p, project); + oracle.checkPermission(token, p, e -> e.project(project)); } - Set notPermitted = Permissions.getPermissionMatrix().entrySet().stream() + Set notPermitted = AuthorizationOracle.Permissions.getPermissionMatrix() + .entrySet().stream() .filter(e -> !e.getValue().contains(RoleAuthority.PROJECT_ADMIN)) .map(Entry::getKey) .collect(Collectors.toSet()); notPermitted.forEach(p -> assertNotAuthorized(() -> - RadarAuthorization.checkPermissionOnProject(token, p, project), + oracle.checkPermission(token, p, e -> e.project(project)), String.format("Token should not have permission %s on project %s", p, project))); } @Test void testCheckPermissionOnOrganization() throws NotAuthorizedException { JwtRadarToken token = new JwtRadarToken(TokenTestUtils.ORGANIZATION_ADMIN_TOKEN); - assertNotAuthorized(() -> { - RadarAuthorization.checkPermissionOnOrganization(token, Permission.ORGANIZATION_CREATE, - "main"); - }, "Token should not be able to create organization"); - RadarAuthorization.checkPermissionOnOrganization(token, Permission.PROJECT_CREATE, "main"); - RadarAuthorization.checkPermissionOnOrganizationAndProject(token, Permission.SUBJECT_CREATE, - "main", "PROJECT1"); - assertNotAuthorized(() -> RadarAuthorization.checkPermissionOnOrganization( - token, Permission.PROJECT_CREATE, "other"), - "Token should not be able to create organization"); - assertNotAuthorized(() -> RadarAuthorization.checkPermissionOnOrganizationAndProject( - token, Permission.SUBJECT_CREATE, "other", - "PROJECT1"), + assertNotAuthorized(() -> oracle.checkPermission(token, Permission.ORGANIZATION_CREATE, + e -> e.organization("main")), "Token should not be able to create organization"); + oracle.checkPermission(token, Permission.PROJECT_CREATE, e -> e.organization("main")); + oracle.checkPermission(token, Permission.SUBJECT_CREATE, + e -> e.organization("main").project("PROJECT1")); + assertNotAuthorized(() -> oracle.checkPermission(token, Permission.PROJECT_CREATE, + e -> e.organization("other")), + "Token should not be able to create project in other organization"); + assertNotAuthorized(() -> oracle.checkPermission( + token, Permission.SUBJECT_CREATE, + e -> e.organization("other").project("PROJECT1")), + "Token should not be able to create subject in other organization"); } @Test void testCheckPermission() throws NotAuthorizedException { RadarToken token = new JwtRadarToken(TokenTestUtils.SUPER_USER_TOKEN); for (Permission p : Permission.values()) { - RadarAuthorization.checkPermission(token, p); + oracle.checkPermission(token, p); } } @@ -80,7 +90,7 @@ void testCheckPermissionOnSelf() throws NotAuthorizedException { String subject = token.getSubject(); for (Permission p : Arrays.asList(Permission.MEASUREMENT_CREATE, Permission.MEASUREMENT_READ, Permission.SUBJECT_UPDATE, Permission.SUBJECT_READ)) { - RadarAuthorization.checkPermissionOnSubject(token, p, project, subject); + oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); } } @@ -91,9 +101,9 @@ void testCheckPermissionOnOtherSubject() { // this token is participant in PROJECT2 RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); String other = "other-subject"; - Permission.stream() + Stream.of(Permission.values()) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermissionOnSubject(token, p, project, other), + () -> oracle.checkPermission(token, p, e -> e.project(project).subject(other)), "Token should not have permission " + p + " on another subject")); } @@ -104,11 +114,11 @@ void testCheckPermissionOnSubject() throws NotAuthorizedException { // this token is participant in PROJECT2 RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); String subject = "some-subject"; - Set permissions = Permission.stream() + Set permissions = Stream.of(Permission.values()) .filter(p -> p.getEntity() == Permission.Entity.SUBJECT) .collect(Collectors.toSet()); for (Permission p : permissions) { - RadarAuthorization.checkPermissionOnSubject(token, p, project, subject); + oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); } } @@ -117,11 +127,11 @@ void testMultipleRolesInProjectToken() throws NotAuthorizedException { String project = "PROJECT2"; RadarToken token = new JwtRadarToken(TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN); String subject = "some-subject"; - Set permissions = Permission.stream() + Set permissions = Stream.of(Permission.values()) .filter(p -> p.getEntity() == Permission.Entity.SUBJECT) .collect(Collectors.toSet()); for (Permission p : permissions) { - RadarAuthorization.checkPermissionOnSubject(token, p, project, subject); + oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); } } @@ -133,10 +143,10 @@ void testCheckPermissionOnSource() { String subject = "some-subject"; String source = "source-1"; - Permission.stream() + Stream.of(Permission.values()) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermissionOnSource( - token, p, project, subject, source), + () -> oracle.checkPermission( + token, p, e -> e.project(project).subject(subject).source(source)), "Token should not have permission " + p + " on another subject")); } @@ -148,12 +158,13 @@ void testCheckPermissionOnOwnSource() throws NotAuthorizedException { String subject = token.getSubject(); String source = "source-1"; // source to use - Set permissions = Permission.stream() + Set permissions = Stream.of(Permission.values()) .filter(p -> p.getEntity() == Entity.MEASUREMENT) .collect(Collectors.toSet()); for (Permission p : permissions) { - RadarAuthorization.checkPermissionOnSource(token, p, project, subject, source); + oracle.checkPermission( + token, p, e -> e.project(project).subject(subject).source(source)); } } @@ -166,45 +177,41 @@ void testScopeOnlyToken() throws NotAuthorizedException { Permission.MEASUREMENT_CREATE); for (Permission p : scope) { - RadarAuthorization.checkPermission(token, p); - RadarAuthorization.checkPermissionOnProject(token, p, ""); - RadarAuthorization.checkPermissionOnSubject(token, p, "", ""); - RadarAuthorization.checkPermissionOnSource(token, p, "", "", ""); + oracle.checkPermission(token, p); + oracle.checkPermission(token, p, e -> e.project("")); + oracle.checkPermission(token, p, e -> e.project("").subject("")); + oracle.checkPermission(token, p, e -> e.project("").subject("").source("")); } // test we can do nothing else, for each of the checkPermission methods - Permission.stream() + Stream.of(Permission.values()) .filter(p -> !scope.contains(p)) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermission(token, p), + () -> oracle.checkPermission(token, p), "Permission " + p + " is granted but not in scope.")); - Permission.stream() + Stream.of(Permission.values()) .filter(p -> !scope.contains(p)) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermissionOnProject(token, p, ""), + () -> oracle.checkPermission(token, p, e -> e.project("")), "Permission " + p + " is granted but not in scope.")); - Permission.stream() + Stream.of(Permission.values()) .filter(p -> !scope.contains(p)) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermissionOnSubject(token, p, "", ""), + () -> oracle.checkPermission(token, p, e -> e.project("").subject("")), "Permission " + p + " is granted but not in scope.")); - Permission.stream() + Stream.of(Permission.values()) .filter(p -> !scope.contains(p)) .forEach(p -> assertNotAuthorized( - () -> RadarAuthorization.checkPermissionOnSource(token, p, "", "", ""), + () -> oracle.checkPermission(token, p, + e -> e.project("").subject("").source("")), "Permission " + p + " is granted but not in scope.")); } private static void assertNotAuthorized(AuthorizationCheck supplier, String message) { - try { - supplier.check(); - fail(message); - } catch (GeneralSecurityException e) { - assertThat(e, instanceOf(NotAuthorizedException.class)); - } + assertThrows(NotAuthorizedException.class, supplier::check, message); } @FunctionalInterface diff --git a/radar-auth/src/test/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfigTest.java b/radar-auth/src/test/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfigTest.java deleted file mode 100644 index c5fe7e81b..000000000 --- a/radar-auth/src/test/java/org/radarbase/auth/config/TokenVerifierPublicKeyConfigTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.radarbase.auth.config; - -import org.junit.contrib.java.lang.system.EnvironmentVariables; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.List; - -import static org.hamcrest.CoreMatchers.hasItems; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; - - -/** - * Created by dverbeec on 19/06/2017. - */ -class TokenVerifierPublicKeyConfigTest { - - public final EnvironmentVariables environmentVariables = new EnvironmentVariables(); - - @Test - void testLoadYamlFileFromClasspath() throws URISyntaxException { - TokenValidatorConfig config = TokenVerifierPublicKeyConfig.readFromFileOrClasspath(); - checkConfig(config); - } - - @Test - void testLoadYamlFileFromEnv() throws URISyntaxException { - ClassLoader loader = Thread.currentThread().getContextClassLoader(); - File configFile = new File(loader.getResource("radar-is.yml").toURI()); - environmentVariables - .set(TokenVerifierPublicKeyConfig.LOCATION_ENV, configFile.getAbsolutePath()); - TokenValidatorConfig config = TokenVerifierPublicKeyConfig.readFromFileOrClasspath(); - checkConfig(config); - } - - private void checkConfig(TokenValidatorConfig config) throws URISyntaxException { - List uris = config.getPublicKeyEndpoints(); - assertThat(uris, hasItems(new URI("http://localhost:8089/oauth/token_key"), - new URI("http://localhost:8089/oauth/token_key"))); - assertEquals(2, uris.size()); - assertEquals("unit_test", config.getResourceName()); - } -} diff --git a/radar-auth/src/test/java/org/radarbase/auth/security/jwk/JsonWebKeyTest.kt b/radar-auth/src/test/java/org/radarbase/auth/security/jwk/JsonWebKeyTest.kt new file mode 100644 index 000000000..3a943a271 --- /dev/null +++ b/radar-auth/src/test/java/org/radarbase/auth/security/jwk/JsonWebKeyTest.kt @@ -0,0 +1,24 @@ +package org.radarbase.auth.security.jwk + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.radarbase.auth.jwks.ECDSAJsonWebKey +import org.radarbase.auth.jwks.JsonWebKey + +class JsonWebKeyTest { + @Test + fun deserialize() { + val result = Json.decodeFromString("""{"kty": "EC", "crv": "EC-512", "x": "abcd", "y": "cdef"}""") + assertInstanceOf(ECDSAJsonWebKey::class.java, result) + assertEquals( + ECDSAJsonWebKey( + kty = "EC", + crv = "EC-512", + x = "abcd", + y = "cdef", + ), result) + } + +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java index 38f04ee93..09aba8250 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java +++ b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java @@ -1,28 +1,37 @@ package org.radarbase.auth.token; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; -import static org.radarbase.auth.authorization.Permission.MEASUREMENT_CREATE; -import static org.radarbase.auth.token.AbstractRadarToken.CLIENT_CREDENTIALS; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.radarbase.auth.authorization.AuthorizationOracle; +import org.radarbase.auth.authorization.EntityRelationService; import java.util.ArrayList; +import java.util.Calendar; import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.radarbase.auth.authorization.Permission.MEASUREMENT_CREATE; +import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; +import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; +import static org.radarbase.auth.token.AbstractRadarToken.CLIENT_CREDENTIALS; + +public class AbstractRadarTokenTest { + private AuthorizationOracle oracle; + private MockToken token; -class AbstractRadarTokenTest { static class MockToken extends AbstractRadarToken { private final Set roles = new HashSet<>(); private final List sources = new ArrayList<>(); - private final List scopes = new ArrayList<>(); + private final Set scopes = new LinkedHashSet<>(); private String grantType = "refresh_token"; - private final List authorities = new ArrayList<>(); - private String subject = null; + private String subject = ""; @Override public Set getRoles() { @@ -30,12 +39,7 @@ public Set getRoles() { } @Override - public List getAuthorities() { - return authorities; - } - - @Override - public List getScopes() { + public Set getScopes() { return scopes; } @@ -61,12 +65,14 @@ public String getUsername() { @Override public Date getIssuedAt() { - return null; + return new Date(); } @Override public Date getExpiresAt() { - return null; + var calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, 3000); + return calendar.getTime(); } @Override @@ -76,27 +82,27 @@ public List getAudience() { @Override public String getToken() { - return null; + return ""; } @Override public String getIssuer() { - return null; + return ""; } @Override public String getType() { - return null; + return ""; } @Override public String getClientId() { - return null; + return ""; } @Override public String getClaimString(String name) { - return null; + return ""; } @Override @@ -105,119 +111,146 @@ public List getClaimList(String name) { } } + public static class MockEntityRelationService implements EntityRelationService { + private final Map projectToOrganization; + + public MockEntityRelationService() { + this(Map.of()); + } + + public MockEntityRelationService(Map projectToOrganization) { + this.projectToOrganization = projectToOrganization; + } + + @Override + public boolean organizationContainsProject(@NotNull String organization, + @NotNull String project) { + return findOrganizationOfProject(project).equals(organization); + } + + @NotNull + @Override + public String findOrganizationOfProject(@NotNull String project) { + return projectToOrganization.getOrDefault(project, "main"); + } + } + + @BeforeEach + public void setUp() { + this.oracle = new AuthorizationOracle(new MockEntityRelationService()); + this.token = new MockToken(); + } + @Test void notHasPermissionWithoutScope() { - MockToken token = new MockToken(); - assertFalse(token.hasPermission(MEASUREMENT_CREATE)); + assertFalse(oracle.hasScope(token, MEASUREMENT_CREATE)); } @Test void notHasPermissionWithoutAuthority() { - MockToken token = new MockToken(); token.scopes.add("MEASUREMENT_CREATE"); - assertFalse(token.hasPermission(MEASUREMENT_CREATE)); + assertFalse(oracle.hasScope(token, MEASUREMENT_CREATE)); } @Test void hasPermissionAsAdmin() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); - token.authorities.add(SYS_ADMIN.authority()); - assertTrue(token.hasPermission(MEASUREMENT_CREATE)); + token.roles.add(new AuthorityReference(SYS_ADMIN)); + assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); } @Test void hasPermissionAsUser() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.roles.add(new AuthorityReference(PARTICIPANT, "some")); - assertTrue(token.hasPermission(MEASUREMENT_CREATE)); + assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); } @Test void hasPermissionAsClient() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.grantType = CLIENT_CREDENTIALS; - assertTrue(token.hasPermission(MEASUREMENT_CREATE)); + assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); } @Test void notHasPermissionOnProjectWithoutScope() { MockToken token = new MockToken(); - assertFalse(token.hasPermissionOnProject(MEASUREMENT_CREATE, "project")); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); } @Test void notHasPermissioOnProjectnWithoutAuthority() { - MockToken token = new MockToken(); - token.scopes.add("MEASUREMENT_CREATE"); - assertFalse(token.hasPermissionOnProject(MEASUREMENT_CREATE, "project")); + token.scopes.add(MEASUREMENT_CREATE.scope()); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); } @Test void hasPermissionOnProjectAsAdmin() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); - token.authorities.add(SYS_ADMIN.authority()); - assertTrue(token.hasPermissionOnProject(MEASUREMENT_CREATE, "project")); + token.roles.add(new AuthorityReference(SYS_ADMIN)); + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); } @Test void hasPermissionOnProjectAsUser() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.roles.add(new AuthorityReference(PARTICIPANT, "project")); - assertFalse(token.hasPermissionOnProject(MEASUREMENT_CREATE, "project")); - assertFalse(token.hasPermissionOnProject(MEASUREMENT_CREATE, "otherProject")); + token.subject = "subject"; + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e + .project("project").subject("subject"))); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("otherProject"))); } @Test void hasPermissionOnProjectAsClient() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.grantType = CLIENT_CREDENTIALS; - assertTrue(token.hasPermissionOnProject(MEASUREMENT_CREATE, "project")); + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); } @Test void notHasPermissionOnSubjectWithoutScope() { - MockToken token = new MockToken(); - assertFalse(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "subject")); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("subject"))); } @Test void notHasPermissioOnSubjectnWithoutAuthority() { MockToken token = new MockToken(); - token.scopes.add("MEASUREMENT_CREATE"); - assertFalse(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "subject")); + token.scopes.add(MEASUREMENT_CREATE.scope()); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("subject"))); } @Test void hasPermissionOnSubjectAsAdmin() { MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); - token.authorities.add(SYS_ADMIN.authority()); - assertTrue(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "subject")); + token.roles.add(new AuthorityReference(SYS_ADMIN)); + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("subject"))); } @Test void hasPermissionOnSubjectAsUser() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.roles.add(new AuthorityReference(PARTICIPANT, "project")); token.subject = "subject"; - assertTrue(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "subject")); - assertFalse(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "otherProject", "subject")); - assertFalse(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "otherSubject")); + + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("subject"))); + assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("otherSubject"))); } @Test void hasPermissionOnSubjectAsClient() { - MockToken token = new MockToken(); token.scopes.add(MEASUREMENT_CREATE.scope()); token.grantType = CLIENT_CREDENTIALS; - assertTrue(token.hasPermissionOnSubject(MEASUREMENT_CREATE, "project", "subject")); + assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, + e -> e.project("project").subject("subject"))); } } diff --git a/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java b/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java index 3536be508..f10963395 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java +++ b/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java @@ -16,6 +16,8 @@ import java.time.Instant; import java.util.Base64; import java.util.Date; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Sets up a keypair for signing the tokens, initialize all kinds of different tokens for tests. @@ -34,7 +36,8 @@ public final class TokenTestUtils { public static final DecodedJWT MULTIPLE_ROLES_IN_PROJECT_TOKEN; public static final String[] AUTHORITIES = {"ROLE_SYS_ADMIN", "ROLE_USER"}; - public static final String[] ALL_SCOPES = Permission.scopes(); + public static final String[] ALL_SCOPES = Stream.of(Permission.values()) + .map(Permission::scope).collect(Collectors.toList()).toArray(new String[0]); public static final String[] ROLES = {"PROJECT1:ROLE_PROJECT_ADMIN", "PROJECT2:ROLE_PARTICIPANT"}; public static final String[] SOURCES = {}; @@ -64,7 +67,7 @@ private TokenTestUtils() { Algorithm algorithm = Algorithm.RSA256(publicKey, provider.getPrivateKey()); PUBLIC_KEY_BODY = "{\n \"keys\" : [ {\n \"alg\" : \"" + algorithm.getName() - + "\",\n \"alg\" : \"RSA\",\n" + + "\",\n \"kty\" : \"RSA\",\n" + " \"value\" : \"-----BEGIN PUBLIC KEY-----\\n" + PUBLIC_KEY_STRING + "\\n-----END PUBLIC KEY-----\"\n} ]\n}"; diff --git a/radar-auth/src/test/resources/radar-is-2.yml b/radar-auth/src/test/resources/radar-is-2.yml deleted file mode 100644 index 655432337..000000000 --- a/radar-auth/src/test/resources/radar-is-2.yml +++ /dev/null @@ -1 +0,0 @@ -resourceName: unit_test diff --git a/radar-auth/src/test/resources/radar-is.yml b/radar-auth/src/test/resources/radar-is.yml deleted file mode 100644 index 81aee0bbb..000000000 --- a/radar-auth/src/test/resources/radar-is.yml +++ /dev/null @@ -1,4 +0,0 @@ -resourceName: unit_test -publicKeyEndpoints: - - http://localhost:8089/oauth/token_key - - http://localhost:8089/oauth/token_key # in our tests we only have one wiremock port open, so we have two times the same uri diff --git a/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java b/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java index 5ef6c52fe..f23d0d515 100644 --- a/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java +++ b/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java @@ -1,17 +1,5 @@ package org.radarbase.management.config; -import java.io.BufferedReader; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.core.type.TypeReference; @@ -33,6 +21,18 @@ import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService; import org.springframework.stereotype.Component; +import java.io.BufferedReader; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + /** * Loads security configs such as oauth-clients, and overriding admin password if specified. * Created by dverbeec on 20/11/2017. diff --git a/src/main/java/org/radarbase/management/config/audit/CustomRevisionListener.java b/src/main/java/org/radarbase/management/config/audit/CustomRevisionListener.java index 2d6acf554..c0ede037a 100644 --- a/src/main/java/org/radarbase/management/config/audit/CustomRevisionListener.java +++ b/src/main/java/org/radarbase/management/config/audit/CustomRevisionListener.java @@ -1,7 +1,7 @@ package org.radarbase.management.config.audit; import org.hibernate.envers.RevisionListener; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.audit.CustomRevisionEntity; import org.radarbase.management.domain.support.AutowireHelper; import org.radarbase.management.security.SpringSecurityAuditorAware; diff --git a/src/main/java/org/radarbase/management/domain/Authority.java b/src/main/java/org/radarbase/management/domain/Authority.java index ee5d00b9b..b45cae9d4 100644 --- a/src/main/java/org/radarbase/management/domain/Authority.java +++ b/src/main/java/org/radarbase/management/domain/Authority.java @@ -13,7 +13,7 @@ import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.envers.Audited; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; /** * An authority (a security role) used by Spring Security. @@ -42,7 +42,7 @@ public Authority(String authorityName) { } public Authority(RoleAuthority role) { - this(role.authority()); + this(role.getAuthority()); } public String getName() { diff --git a/src/main/java/org/radarbase/management/domain/Group.java b/src/main/java/org/radarbase/management/domain/Group.java index 6008009fb..ccbe4e851 100644 --- a/src/main/java/org/radarbase/management/domain/Group.java +++ b/src/main/java/org/radarbase/management/domain/Group.java @@ -11,7 +11,7 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/src/main/java/org/radarbase/management/domain/MetaToken.java b/src/main/java/org/radarbase/management/domain/MetaToken.java index 3f5e1b0bd..1353037bc 100644 --- a/src/main/java/org/radarbase/management/domain/MetaToken.java +++ b/src/main/java/org/radarbase/management/domain/MetaToken.java @@ -19,7 +19,7 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.envers.Audited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; @Entity diff --git a/src/main/java/org/radarbase/management/domain/Organization.java b/src/main/java/org/radarbase/management/domain/Organization.java index f728adae7..108587d6e 100644 --- a/src/main/java/org/radarbase/management/domain/Organization.java +++ b/src/main/java/org/radarbase/management/domain/Organization.java @@ -3,7 +3,7 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.envers.Audited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; import javax.persistence.Column; diff --git a/src/main/java/org/radarbase/management/domain/Project.java b/src/main/java/org/radarbase/management/domain/Project.java index 26e7c1179..04f6c9c27 100644 --- a/src/main/java/org/radarbase/management/domain/Project.java +++ b/src/main/java/org/radarbase/management/domain/Project.java @@ -10,7 +10,7 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.envers.Audited; import org.hibernate.envers.NotAudited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.enumeration.ProjectStatus; import org.radarbase.management.domain.support.AbstractEntityListener; diff --git a/src/main/java/org/radarbase/management/domain/Source.java b/src/main/java/org/radarbase/management/domain/Source.java index 25b5258d4..bffb3828d 100644 --- a/src/main/java/org/radarbase/management/domain/Source.java +++ b/src/main/java/org/radarbase/management/domain/Source.java @@ -6,7 +6,7 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.envers.Audited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; import javax.persistence.CollectionTable; diff --git a/src/main/java/org/radarbase/management/domain/SourceData.java b/src/main/java/org/radarbase/management/domain/SourceData.java index 48afa665b..0e222c656 100644 --- a/src/main/java/org/radarbase/management/domain/SourceData.java +++ b/src/main/java/org/radarbase/management/domain/SourceData.java @@ -4,7 +4,7 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.envers.Audited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; import javax.persistence.Column; diff --git a/src/main/java/org/radarbase/management/domain/SourceType.java b/src/main/java/org/radarbase/management/domain/SourceType.java index 0722ba75c..e2c86f11a 100644 --- a/src/main/java/org/radarbase/management/domain/SourceType.java +++ b/src/main/java/org/radarbase/management/domain/SourceType.java @@ -10,7 +10,7 @@ import org.hibernate.annotations.Cascade; import org.hibernate.annotations.CascadeType; import org.hibernate.envers.Audited; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; import javax.persistence.Column; diff --git a/src/main/java/org/radarbase/management/domain/Subject.java b/src/main/java/org/radarbase/management/domain/Subject.java index 946fd727d..c8ffb1329 100644 --- a/src/main/java/org/radarbase/management/domain/Subject.java +++ b/src/main/java/org/radarbase/management/domain/Subject.java @@ -234,7 +234,7 @@ public String getPersonName() { */ public Optional getActiveProject() { return this.getUser().getRoles().stream() - .filter(r -> r.getAuthority().getName().equals(PARTICIPANT.authority())) + .filter(r -> r.getAuthority().getName().equals(PARTICIPANT.getAuthority())) .findFirst() .map(Role::getProject); } diff --git a/src/main/java/org/radarbase/management/domain/User.java b/src/main/java/org/radarbase/management/domain/User.java index 023e966b3..67a977f10 100644 --- a/src/main/java/org/radarbase/management/domain/User.java +++ b/src/main/java/org/radarbase/management/domain/User.java @@ -10,7 +10,7 @@ import org.hibernate.annotations.CascadeType; import org.hibernate.envers.Audited; import org.hibernate.validator.constraints.Email; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.support.AbstractEntityListener; import javax.persistence.Column; diff --git a/src/main/java/org/radarbase/management/domain/support/AbstractEntityListener.java b/src/main/java/org/radarbase/management/domain/support/AbstractEntityListener.java index fead626b5..410c0391f 100644 --- a/src/main/java/org/radarbase/management/domain/support/AbstractEntityListener.java +++ b/src/main/java/org/radarbase/management/domain/support/AbstractEntityListener.java @@ -1,6 +1,6 @@ package org.radarbase.management.domain.support; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.domain.AbstractEntity; import org.radarbase.management.security.SpringSecurityAuditorAware; import org.slf4j.Logger; diff --git a/src/main/java/org/radarbase/management/repository/CustomAuditEventRepository.java b/src/main/java/org/radarbase/management/repository/CustomAuditEventRepository.java index d5f86148f..c66e0fa49 100644 --- a/src/main/java/org/radarbase/management/repository/CustomAuditEventRepository.java +++ b/src/main/java/org/radarbase/management/repository/CustomAuditEventRepository.java @@ -4,7 +4,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.management.config.audit.AuditEventConverter; import org.radarbase.management.domain.PersistentAuditEvent; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/org/radarbase/management/repository/OrganizationRepository.java b/src/main/java/org/radarbase/management/repository/OrganizationRepository.java index 77bac7ebc..423bda374 100644 --- a/src/main/java/org/radarbase/management/repository/OrganizationRepository.java +++ b/src/main/java/org/radarbase/management/repository/OrganizationRepository.java @@ -1,5 +1,6 @@ package org.radarbase.management.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -23,5 +24,6 @@ public interface OrganizationRepository extends JpaRepository findAllByProjectNames(@Param("projectNames") List projectNames); + List findAllByProjectNames( + @Param("projectNames") Collection projectNames); } diff --git a/src/main/java/org/radarbase/management/repository/ProjectRepository.java b/src/main/java/org/radarbase/management/repository/ProjectRepository.java index b17dd3da0..2a698e5d9 100644 --- a/src/main/java/org/radarbase/management/repository/ProjectRepository.java +++ b/src/main/java/org/radarbase/management/repository/ProjectRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.repository.history.RevisionRepository; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -34,8 +35,8 @@ public interface ProjectRepository extends JpaRepository, + "OR project.organization.name in (:organizationNames)") Page findAllWithEagerRelationshipsInOrganizationsOrProjects( Pageable pageable, - @Param("organizationNames") List organizationNames, - @Param("projectNames") List projectNames); + @Param("organizationNames") Collection organizationNames, + @Param("projectNames") Collection projectNames); @Query("select project from Project project " + "WHERE project.organization.name = :organization_name") @@ -56,6 +57,7 @@ List findAllByOrganizationName( @Query("select project from Project project " + "left join fetch project.sourceTypes " + "left join fetch project.groups " + + "left join fetch project.organization " + "where project.projectName = :name") Optional findOneWithEagerRelationshipsByName(@Param("name") String name); diff --git a/src/main/java/org/radarbase/management/repository/filters/UserFilter.java b/src/main/java/org/radarbase/management/repository/filters/UserFilter.java index af7dc318b..c2df8be75 100644 --- a/src/main/java/org/radarbase/management/repository/filters/UserFilter.java +++ b/src/main/java/org/radarbase/management/repository/filters/UserFilter.java @@ -52,13 +52,13 @@ public Predicate toPredicate(Root root, @Nonnull CriteriaQuery query, private void filterRoles(PredicateBuilder predicates, Join roleJoin, CriteriaQuery query) { Stream authoritiesFiltered = Stream.of(RoleAuthority.values()) - .filter(java.util.function.Predicate.not(RoleAuthority::isPersonal)); + .filter(r -> !r.isPersonal); boolean allowNoRole = true; if (predicates.isValidValue(authority)) { String authorityUpper = authority.toUpperCase(Locale.ROOT); authoritiesFiltered = authoritiesFiltered - .filter(r -> r != null && r.authority().contains(authorityUpper)); + .filter(r -> r != null && r.getAuthority().contains(authorityUpper)); allowNoRole = false; } List authoritiesAllowed = authoritiesFiltered.collect(Collectors.toList()); @@ -125,10 +125,10 @@ private boolean addAllowedAuthorities(PredicateBuilder predicates, Stream authorityStream = authorities.stream(); if (scope != null) { - authorityStream = authorityStream.filter(r -> r.scope() == scope); + authorityStream = authorityStream.filter(r -> r.getScope() == scope); } List authorityNames = authorityStream - .map(RoleAuthority::authority) + .map(RoleAuthority::getAuthority) .collect(Collectors.toList()); if (!authorityNames.isEmpty()) { diff --git a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java index cf6b39cc1..5cbc492c8 100644 --- a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java +++ b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java @@ -1,7 +1,7 @@ package org.radarbase.management.security; import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.token.JwtRadarToken; +import org.radarbase.auth.jwt.JwtRadarToken; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.SubjectRepository; @@ -65,7 +65,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, var roles = user.getRoles().stream() .map(role -> { var auth = role.getAuthority().getName(); - return switch (role.getRole().scope()) { + return switch (role.getRole().getScope()) { case GLOBAL -> auth; case ORGANIZATION -> role.getOrganization().getName() + ":" + auth; @@ -83,7 +83,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, Permission permission = Permission.ofScope(scope); return user.getRoles().stream() .map(Role::getRole) - .anyMatch(permission::isRoleAllowed); + .anyMatch(r -> r.mayBeGranted(permission)); }) .collect(Collectors.toCollection(TreeSet::new)); diff --git a/radar-auth/src/main/java/org/radarbase/auth/config/Constants.java b/src/main/java/org/radarbase/management/security/Constants.java similarity index 90% rename from radar-auth/src/main/java/org/radarbase/auth/config/Constants.java rename to src/main/java/org/radarbase/management/security/Constants.java index 790c1a90a..cc563f8c8 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/config/Constants.java +++ b/src/main/java/org/radarbase/management/security/Constants.java @@ -1,4 +1,4 @@ -package org.radarbase.auth.config; +package org.radarbase.management.security; /** * Application constants. diff --git a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java index c13bc0761..8a4e49423 100644 --- a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java @@ -85,7 +85,7 @@ protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, } if (token == null) { try { - token = SessionRadarToken.from(validator.validateAccessToken(getToken(httpRequest, + token = SessionRadarToken.from(validator.authenticateBlocking(getToken(httpRequest, httpResponse))); } catch (TokenValidationException ex) { logger.error(ex.getMessage()); @@ -105,7 +105,7 @@ protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, var roles = user.get().getRoles().stream() .map(role -> { var auth = role.getRole(); - return switch (role.getRole().scope()) { + return switch (role.getRole().getScope()) { case GLOBAL -> new AuthorityReference(auth); case ORGANIZATION -> new AuthorityReference(auth, role.getOrganization().getName()); diff --git a/src/main/java/org/radarbase/management/security/RadarAuthentication.java b/src/main/java/org/radarbase/management/security/RadarAuthentication.java index f2bb7bcb8..f3c8efd3c 100644 --- a/src/main/java/org/radarbase/management/security/RadarAuthentication.java +++ b/src/main/java/org/radarbase/management/security/RadarAuthentication.java @@ -9,6 +9,7 @@ package org.radarbase.management.security; +import org.radarbase.auth.token.AuthorityReference; import org.radarbase.auth.token.RadarToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -29,7 +30,9 @@ public class RadarAuthentication implements Authentication, Principal { public RadarAuthentication(@Nonnull RadarToken token) { this.token = token; isAuthenticated = true; - authorities = token.getAuthorities().stream() + authorities = token.getRoles().stream() + .map(AuthorityReference::getAuthority) + .distinct() .map(a -> (GrantedAuthority) () -> a) .collect(Collectors.toList()); } diff --git a/src/main/java/org/radarbase/management/security/SessionRadarToken.java b/src/main/java/org/radarbase/management/security/SessionRadarToken.java index 1c5c0359f..2f8ca352a 100644 --- a/src/main/java/org/radarbase/management/security/SessionRadarToken.java +++ b/src/main/java/org/radarbase/management/security/SessionRadarToken.java @@ -9,6 +9,7 @@ package org.radarbase.management.security; +import org.jetbrains.annotations.NotNull; import org.radarbase.auth.token.AbstractRadarToken; import org.radarbase.auth.token.AuthorityReference; import org.radarbase.auth.token.RadarToken; @@ -22,9 +23,8 @@ public class SessionRadarToken extends AbstractRadarToken implements Serializabl private final Set roles; private final String subject; private final String token; - private final List scopes; + private final Set scopes; private final List audience; - private final List authorities; private final List sources; private final String grantType; private final Date issuedAt; @@ -44,9 +44,8 @@ private SessionRadarToken(RadarToken token, Set roles) { this.roles = Set.copyOf(roles); this.subject = token.getSubject(); this.token = token.getToken(); - this.scopes = List.copyOf(token.getScopes()); + this.scopes = Set.copyOf(token.getScopes()); this.audience = List.copyOf(token.getAudience()); - this.authorities = List.copyOf(token.getAuthorities()); this.sources = List.copyOf(token.getSources()); this.grantType = token.getGrantType(); this.issuedAt = token.getIssuedAt(); @@ -57,81 +56,90 @@ private SessionRadarToken(RadarToken token, Set roles) { this.username = token.getUsername(); } + @NotNull @Override public Set getRoles() { return roles; } + @NotNull @Override - public List getAuthorities() { - return authorities; - } - - @Override - public List getScopes() { + public Set getScopes() { return scopes; } + @NotNull @Override public List getSources() { return sources; } + @NotNull @Override public String getGrantType() { return grantType; } + @NotNull @Override public String getSubject() { return subject; } + @NotNull @Override public Date getIssuedAt() { return issuedAt; } + @NotNull @Override public Date getExpiresAt() { return expiresAt; } + @NotNull @Override public List getAudience() { return audience; } + @NotNull @Override public String getToken() { return token; } + @NotNull @Override public String getIssuer() { return issuer; } + @NotNull @Override public String getType() { return type; } + @NotNull @Override public String getClientId() { return clientId; } @Override - public String getClaimString(String name) { - return null; + public String getClaimString(@NotNull String name) { + return ""; } + @NotNull @Override - public List getClaimList(String name) { + public List getClaimList(@NotNull String name) { return List.of(); } + @NotNull @Override public String getUsername() { return username; diff --git a/src/main/java/org/radarbase/management/security/jwt/ManagementPortalJwtAccessTokenConverter.java b/src/main/java/org/radarbase/management/security/jwt/ManagementPortalJwtAccessTokenConverter.java index 5c17d0fe6..b7ad2021c 100644 --- a/src/main/java/org/radarbase/management/security/jwt/ManagementPortalJwtAccessTokenConverter.java +++ b/src/main/java/org/radarbase/management/security/jwt/ManagementPortalJwtAccessTokenConverter.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; -import org.radarbase.auth.authentication.AlgorithmLoader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.common.DefaultExpiringOAuth2RefreshToken; @@ -120,7 +119,7 @@ public OAuth2Authentication extractAuthentication(Map map) { public final void setAlgorithm(Algorithm algorithm) { this.algorithm = algorithm; if (verifiers.isEmpty()) { - this.verifiers.add(AlgorithmLoader.buildVerifier(algorithm, RES_MANAGEMENT_PORTAL)); + this.verifiers.add(JWT.require(algorithm).withAudience(RES_MANAGEMENT_PORTAL).build()); } } diff --git a/src/main/java/org/radarbase/management/security/jwt/ManagementPortalOauthKeyStoreHandler.java b/src/main/java/org/radarbase/management/security/jwt/ManagementPortalOauthKeyStoreHandler.java index 0ae7dea83..aa9e2cf30 100644 --- a/src/main/java/org/radarbase/management/security/jwt/ManagementPortalOauthKeyStoreHandler.java +++ b/src/main/java/org/radarbase/management/security/jwt/ManagementPortalOauthKeyStoreHandler.java @@ -4,8 +4,9 @@ import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import org.radarbase.auth.authentication.TokenValidator; -import org.radarbase.auth.config.TokenValidatorConfig; -import org.radarbase.auth.security.jwk.JavaWebKeySet; +import org.radarbase.auth.jwks.JsonWebKeySet; +import org.radarbase.auth.jwks.JwkAlgorithmParser; +import org.radarbase.auth.jwks.JwksTokenVerifierLoader; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.security.jwt.algorithm.EcdsaJwtAlgorithm; import org.radarbase.management.security.jwt.algorithm.JwtAlgorithm; @@ -22,8 +23,6 @@ import javax.annotation.Nullable; import javax.servlet.ServletContext; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; import java.security.KeyPair; import java.security.KeyStore; import java.security.KeyStoreException; @@ -38,7 +37,6 @@ import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; @@ -50,7 +48,7 @@ /** * Similar to Spring's - * {@link org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory}. However + * {@link org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory}. However, * this class does not assume a specific key type, while the Spring factory assumes RSA keys. */ @Component @@ -178,8 +176,8 @@ private List loadVerifiersPublicKeyAliasList() { * Returns configured public keys of token verifiers. * @return List of public keys for token verification. */ - public JavaWebKeySet loadJwks() { - return new JavaWebKeySet(this.verifierPublicKeyAliasList.stream() + public JsonWebKeySet loadJwks() { + return new JsonWebKeySet(this.verifierPublicKeyAliasList.stream() .map(this::getKeyPair) .map(ManagementPortalOauthKeyStoreHandler::getJwtAlgorithm) .filter(Objects::nonNull) @@ -310,28 +308,14 @@ private static Algorithm getAlgorithmFromKeyPair(KeyPair keyPair) { } } + /** Get the default token validator. */ public TokenValidator getTokenValidator() { - return new TokenValidator(getKeystoreConfigsForVerifiers()); - } - - private TokenValidatorConfig getKeystoreConfigsForVerifiers() { - return new TokenValidatorConfig() { - @Override - public List getPublicKeyEndpoints() { - try { - URI managementPortalUrl = new URI(managementPortalBaseUrl + "/oauth/token_key"); - return Collections.singletonList(managementPortalUrl); - } catch (URISyntaxException e) { - logger.error("Could not create publicKey end point URI"); - return Collections.emptyList(); - } - } - - @Override - public String getResourceName() { - return RES_MANAGEMENT_PORTAL; - } - }; + var jwksLoader = new JwksTokenVerifierLoader( + managementPortalBaseUrl + "/oauth/token_key", + "res_ManagementPortal", + new JwkAlgorithmParser() + ); + return new TokenValidator(List.of(jwksLoader)); } public List getRefreshTokenVerifiers() { diff --git a/src/main/java/org/radarbase/management/security/jwt/algorithm/AsymmetricalJwtAlgorithm.java b/src/main/java/org/radarbase/management/security/jwt/algorithm/AsymmetricalJwtAlgorithm.java index d3676dd52..a0145c422 100644 --- a/src/main/java/org/radarbase/management/security/jwt/algorithm/AsymmetricalJwtAlgorithm.java +++ b/src/main/java/org/radarbase/management/security/jwt/algorithm/AsymmetricalJwtAlgorithm.java @@ -3,7 +3,8 @@ import java.security.KeyPair; import java.util.Base64; -import org.radarbase.auth.security.jwk.JavaWebKey; +import org.radarbase.auth.jwks.JsonWebKey; +import org.radarbase.auth.jwks.MPJsonWebKey; public abstract class AsymmetricalJwtAlgorithm implements JwtAlgorithm { @@ -30,10 +31,10 @@ public String getVerifierKeyEncodedString() { } @Override - public JavaWebKey getJwk() { - return new JavaWebKey() - .alg(this.getAlgorithm().getName()) - .kty(this.getKeyType()) - .value(this.getVerifierKeyEncodedString()); + public JsonWebKey getJwk() { + return new MPJsonWebKey( + this.getAlgorithm().getName(), + this.getKeyType(), + this.getVerifierKeyEncodedString()); } } diff --git a/src/main/java/org/radarbase/management/security/jwt/algorithm/JwtAlgorithm.java b/src/main/java/org/radarbase/management/security/jwt/algorithm/JwtAlgorithm.java index 4678f28ec..d7d026371 100644 --- a/src/main/java/org/radarbase/management/security/jwt/algorithm/JwtAlgorithm.java +++ b/src/main/java/org/radarbase/management/security/jwt/algorithm/JwtAlgorithm.java @@ -1,7 +1,7 @@ package org.radarbase.management.security.jwt.algorithm; import com.auth0.jwt.algorithms.Algorithm; -import org.radarbase.auth.security.jwk.JavaWebKey; +import org.radarbase.auth.jwks.JsonWebKey; /** * Encodes a signing and verification algorithm for JWT. @@ -20,7 +20,7 @@ public interface JwtAlgorithm { /** * JavaWebKey for given algorithm for token verification. - * @return instance of {@link JavaWebKey} + * @return instance of {@link JsonWebKey} */ - JavaWebKey getJwk(); + JsonWebKey getJwk(); } diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt new file mode 100644 index 000000000..c636bbe1f --- /dev/null +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -0,0 +1,85 @@ +package org.radarbase.management.service + +import org.radarbase.auth.authorization.* +import org.radarbase.auth.exception.NotAuthorizedException +import org.radarbase.auth.token.RadarToken +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.util.* +import java.util.function.Consumer +import kotlin.jvm.Throws + +@Service +open class AuthService { + @Autowired + private lateinit var projectService: ProjectService + + @Autowired(required = false) + private var token: RadarToken? = null + + private val oracle: AuthorizationOracle = AuthorizationOracle( + object : EntityRelationService { + override fun findOrganizationOfProject(project: String): String { + return projectService.findOneByName(project).organization.name + } + } + ) + + @Throws(NotAuthorizedException::class) + open fun checkScope(permission: Permission) { + val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") + oracle.checkScope(token, permission) + } + + @JvmOverloads + @Throws(NotAuthorizedException::class) + open fun checkPermission( + permission: Permission, + builder: Consumer? = null, + scope: Permission.Entity = permission.entity, + ) { + val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") + oracle.checkPermission(token, permission, if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global, scope) + } + + @JvmOverloads + open fun hasPermission( + permission: Permission, + builder: Consumer? = null, + scope: Permission.Entity = permission.entity, + ): Boolean { + val token = token ?: return false + return oracle.hasPermission( + token, + permission, + if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global, + scope + ) + } + + @JvmOverloads + open fun hasPermission( + permission: Permission, + entityDetails: EntityDetails, + scope: Permission.Entity = permission.entity, + ): Boolean { + val token = token ?: return false + return oracle.hasPermission(token, permission, entityDetails, scope) + } + + @JvmOverloads + @Throws(NotAuthorizedException::class) + open fun checkPermission( + permission: Permission, + entityDetails: EntityDetails, + scope: Permission.Entity = permission.entity, + ) { + val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") + oracle.checkPermission(token, permission, entityDetails, scope) + } + + open fun referentsByScope(permission: Permission): AuthorityReferenceSet { + val token = token ?: return AuthorityReferenceSet() + return oracle.referentsByScope(token, permission) + } +} diff --git a/src/main/java/org/radarbase/management/service/OrganizationService.java b/src/main/java/org/radarbase/management/service/OrganizationService.java index 0144dbb30..2c9182528 100644 --- a/src/main/java/org/radarbase/management/service/OrganizationService.java +++ b/src/main/java/org/radarbase/management/service/OrganizationService.java @@ -1,8 +1,7 @@ package org.radarbase.management.service; -import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Organization; +import org.radarbase.management.domain.Project; import org.radarbase.management.repository.OrganizationRepository; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.service.dto.OrganizationDTO; @@ -15,9 +14,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -41,11 +41,10 @@ public class OrganizationService { @Autowired private OrganizationMapper organizationMapper; - @Autowired - private RadarToken token; - @Autowired private ProjectMapper projectMapper; + @Autowired + private AuthService authService; /** * Save an organization. @@ -69,21 +68,20 @@ public OrganizationDTO save(OrganizationDTO organizationDto) { public List findAll() { List organizationsOfUser; - if (token.hasGlobalPermission(ORGANIZATION_READ)) { + var referents = authService.referentsByScope(ORGANIZATION_READ); + + if (referents.getGlobal()) { organizationsOfUser = organizationRepository.findAll(); } else { - List projectNames = token.getReferentsWithPermission( - RoleAuthority.Scope.PROJECT, ORGANIZATION_READ) - .collect(Collectors.toList()); + Set projectNames = referents.getProjects(); - Stream organizationsOfProject = projectNames.isEmpty() - ? Stream.of() - : organizationRepository.findAllByProjectNames(projectNames).stream(); + Stream organizationsOfProject = referents.hasProjects() + ? organizationRepository.findAllByProjectNames(projectNames).stream() + : Stream.of(); - Stream organizationsOfRole = token.getReferentsWithPermission( - RoleAuthority.Scope.ORGANIZATION, ORGANIZATION_READ) - .flatMap(name -> organizationRepository.findOneByName(name).stream()) - .filter(Objects::nonNull); + Stream organizationsOfRole = referents.getOrganizations() + .stream() + .flatMap(name -> organizationRepository.findOneByName(name).stream()); organizationsOfUser = Stream.concat(organizationsOfRole, organizationsOfProject) .distinct() @@ -113,9 +111,23 @@ public Optional findByName(String name) { */ @Transactional(readOnly = true) public List findAllProjectsByOrganizationName(String organizationName) { - return projectRepository.findAllByOrganizationName(organizationName).stream() - .filter(project -> token.hasPermissionOnOrganizationAndProject( - ORGANIZATION_READ, organizationName, project.getProjectName())) + var referents = authService.referentsByScope(ORGANIZATION_READ); + if (referents.isEmpty()) { + return Collections.emptyList(); + } + + Stream projectStream; + + if (referents.getGlobal() || referents.hasOrganization(organizationName)) { + projectStream = projectRepository.findAllByOrganizationName(organizationName).stream(); + } else if (referents.hasProjects()) { + projectStream = projectRepository.findAllByOrganizationName(organizationName).stream() + .filter(project -> referents.hasProject(project.getProjectName())); + } else { + return List.of(); + } + + return projectStream .map(projectMapper::projectToProjectDTO) .collect(Collectors.toList()); } diff --git a/src/main/java/org/radarbase/management/service/ProjectService.java b/src/main/java/org/radarbase/management/service/ProjectService.java index 15c2451c2..766e879a6 100644 --- a/src/main/java/org/radarbase/management/service/ProjectService.java +++ b/src/main/java/org/radarbase/management/service/ProjectService.java @@ -1,7 +1,5 @@ package org.radarbase.management.service; -import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.ProjectRepository; @@ -15,13 +13,13 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import static org.radarbase.auth.authorization.Permission.PROJECT_READ; import static org.radarbase.management.web.rest.errors.EntityName.PROJECT; @@ -45,7 +43,7 @@ public class ProjectService { private SourceTypeMapper sourceTypeMapper; @Autowired - private RadarToken token; + private AuthService authService; /** @@ -70,19 +68,14 @@ public ProjectDTO save(ProjectDTO projectDto) { public Page findAll(Boolean fetchMinimal, Pageable pageable) { Page projects; - if (token.hasGlobalPermission(PROJECT_READ)) { + var referents = authService.referentsByScope(PROJECT_READ); + if (referents.isEmpty()) { + projects = new PageImpl<>(List.of()); + } else if (referents.getGlobal()) { projects = projectRepository.findAllWithEagerRelationships(pageable); } else { - List projectNames = token.getReferentsWithPermission( - RoleAuthority.Scope.PROJECT, PROJECT_READ) - .collect(Collectors.toList()); - - List organizationNames = token.getReferentsWithPermission( - RoleAuthority.Scope.ORGANIZATION, PROJECT_READ) - .collect(Collectors.toList()); - projects = projectRepository.findAllWithEagerRelationshipsInOrganizationsOrProjects( - pageable, organizationNames, projectNames); + pageable, referents.getOrganizations(), referents.getProjects()); } if (!fetchMinimal) { diff --git a/src/main/java/org/radarbase/management/service/RoleService.java b/src/main/java/org/radarbase/management/service/RoleService.java index 83d37d8c2..42b3a06ce 100644 --- a/src/main/java/org/radarbase/management/service/RoleService.java +++ b/src/main/java/org/radarbase/management/service/RoleService.java @@ -87,15 +87,15 @@ public List findAll() { List currentUserAuthorities = currentUser.getAuthorities().stream() .map(Authority::getName).collect(Collectors.toList()); - if (currentUserAuthorities.contains(RoleAuthority.SYS_ADMIN.authority())) { + if (currentUserAuthorities.contains(RoleAuthority.SYS_ADMIN.getAuthority())) { log.debug("Request to get all Roles"); return roleRepository.findAll().stream() .map(roleMapper::roleToRoleDTO) .collect(Collectors.toList()); - } else if (currentUserAuthorities.contains(RoleAuthority.PROJECT_ADMIN.authority())) { + } else if (currentUserAuthorities.contains(RoleAuthority.PROJECT_ADMIN.getAuthority())) { log.debug("Request to get project admin's project Projects"); return currentUser.getRoles().stream() - .filter(role -> RoleAuthority.PROJECT_ADMIN.authority() + .filter(role -> RoleAuthority.PROJECT_ADMIN.getAuthority() .equals(role.getAuthority().getName())) .map(r -> r.getProject().getProjectName()) .distinct() @@ -117,7 +117,7 @@ public List findSuperAdminRoles() { log.debug("Request to get admin Roles"); return roleRepository - .findRolesByAuthorityName(RoleAuthority.SYS_ADMIN.authority()).stream() + .findRolesByAuthorityName(RoleAuthority.SYS_ADMIN.getAuthority()).stream() .map(roleMapper::roleToRoleDTO) .collect(Collectors.toCollection(LinkedList::new)); } @@ -162,14 +162,14 @@ public static RoleAuthority getRoleAuthority(RoleDTO roleDto) { Collections.singletonMap("authorityName", roleDto.getAuthorityName())); } - if (authority.scope() == RoleAuthority.Scope.ORGANIZATION + if (authority.getScope() == RoleAuthority.Scope.ORGANIZATION && roleDto.getOrganizationId() == null) { throw new BadRequestException("Authority with " + "authorityName should have organization ID", USER, ErrorConstants.ERR_INVALID_AUTHORITY, Collections.singletonMap("authorityName", roleDto.getAuthorityName())); } - if (authority.scope() == RoleAuthority.Scope.PROJECT + if (authority.getScope() == RoleAuthority.Scope.PROJECT && roleDto.getProjectId() == null) { throw new BadRequestException("Authority with " + "authorityName should have project ID", @@ -185,7 +185,7 @@ public static RoleAuthority getRoleAuthority(RoleDTO roleDto) { * @return role from database */ public Role getGlobalRole(RoleAuthority role) { - return roleRepository.findRolesByAuthorityName(role.authority()).stream() + return roleRepository.findRolesByAuthorityName(role.getAuthority()).stream() .findAny() .orElseGet(() -> createNewRole(role, r -> { })); } @@ -198,13 +198,13 @@ public Role getGlobalRole(RoleAuthority role) { */ public Role getOrganizationRole(RoleAuthority role, Long organizationId) { return roleRepository.findOneByOrganizationIdAndAuthorityName( - organizationId, role.authority()) + organizationId, role.getAuthority()) .orElseGet(() -> createNewRole(role, r -> { r.setOrganization(organizationRepository.findById(organizationId) .orElseThrow(() -> new NotFoundException( "Cannot find organization for authority", USER, ErrorConstants.ERR_INVALID_AUTHORITY, - Map.of("authorityName", role.authority(), + Map.of("authorityName", role.getAuthority(), "projectId", organizationId.toString())))); })); @@ -218,13 +218,13 @@ public Role getOrganizationRole(RoleAuthority role, Long organizationId) { */ public Role getProjectRole(RoleAuthority role, Long projectId) { return roleRepository.findOneByProjectIdAndAuthorityName( - projectId, role.authority()) + projectId, role.getAuthority()) .orElseGet(() -> createNewRole(role, r -> { r.setProject(projectRepository.findByIdWithOrganization(projectId) .orElseThrow(() -> new NotFoundException( "Cannot find project for authority", USER, ErrorConstants.ERR_INVALID_AUTHORITY, - Map.of("authorityName", role.authority(), + Map.of("authorityName", role.getAuthority(), "projectId", projectId.toString())))); })); @@ -244,7 +244,7 @@ public List getRolesByProject(String projectName) { } private Authority getAuthority(RoleAuthority role) { - return authorityRepository.findByAuthorityName(role.authority()) + return authorityRepository.findByAuthorityName(role.getAuthority()) .orElseGet(() -> authorityRepository.saveAndFlush(new Authority(role))); } diff --git a/src/main/java/org/radarbase/management/service/SourceService.java b/src/main/java/org/radarbase/management/service/SourceService.java index 342e72047..907ff5e51 100644 --- a/src/main/java/org/radarbase/management/service/SourceService.java +++ b/src/main/java/org/radarbase/management/service/SourceService.java @@ -1,20 +1,7 @@ package org.radarbase.management.service; -import static org.hibernate.id.IdentifierGenerator.ENTITY_NAME; -import static org.radarbase.auth.authorization.Permission.SOURCE_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnSource; -import static org.radarbase.management.web.rest.errors.EntityName.SOURCE; - -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Source; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.ProjectRepository; @@ -34,6 +21,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.hibernate.id.IdentifierGenerator.ENTITY_NAME; +import static org.radarbase.auth.authorization.Permission.SOURCE_UPDATE; +import static org.radarbase.management.web.rest.errors.EntityName.SOURCE; + /** * Service Implementation for managing Source. */ @@ -54,6 +52,8 @@ public class SourceService { @Autowired private SourceTypeMapper sourceTypeMapper; + @Autowired + private AuthService authService; /** * Save a Source. @@ -212,26 +212,26 @@ public MinimalSourceDetailsDTO safeUpdateOfAttributes(Source sourceToUpdate, * Does not allow to transfer if new project does not have valid source-type. * * @param sourceDto source details to update. - * @param jwt authorization token. * @return updated source. */ @Transactional - public Optional updateSource(SourceDTO sourceDto, RadarToken jwt) + public Optional updateSource(SourceDTO sourceDto) throws NotAuthorizedException { Optional existingSourceOpt = sourceRepository.findById(sourceDto.getId()); if (existingSourceOpt.isEmpty()) { return Optional.empty(); } Source existingSource = existingSourceOpt.get(); - String project = existingSource.getProject() != null - ? existingSource.getProject().getProjectName() - : null; - String user = (existingSource.getSubject() != null - && existingSource.getSubject().getUser() != null) - ? existingSource.getSubject().getUser().getLogin() - : null; - - checkPermissionOnSource(jwt, SOURCE_UPDATE, project, user, existingSource.getSourceName()); + authService.checkPermission(SOURCE_UPDATE, e -> { + e.source(existingSource.getSourceName()); + if (existingSource.getProject() != null) { + e.project(existingSource.getProject().getProjectName()); + } + if (existingSource.getSubject() != null + && existingSource.getSubject().getUser() != null) { + e.subject(existingSource.getSubject().getUser().getLogin()); + } + }); // if the source is being transferred to another project. if (!existingSource.getProject().getId().equals(sourceDto.getProject().getId())) { diff --git a/src/main/java/org/radarbase/management/service/SubjectService.java b/src/main/java/org/radarbase/management/service/SubjectService.java index cf0ed8ca8..496089606 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.java +++ b/src/main/java/org/radarbase/management/service/SubjectService.java @@ -168,10 +168,11 @@ private Group getSubjectGroup(Project project, String groupName) { */ private Role getProjectParticipantRole(Project project, RoleAuthority authority) { return roleRepository.findOneByProjectIdAndAuthorityName(project.getId(), - authority.authority()) + authority.getAuthority()) .orElseGet(() -> { Role subjectRole = new Role(); - Authority auth = authorityRepository.findByAuthorityName(authority.authority()) + Authority auth = authorityRepository.findByAuthorityName( + authority.getAuthority()) .orElseGet(() -> authorityRepository.save(new Authority(authority))); subjectRole.setAuthority(auth); subjectRole.setProject(project); @@ -219,7 +220,7 @@ private Set updateParticipantRoles(Subject subject, SubjectDTO subjectDto) Stream existingRoles = subject.getUser().getRoles().stream() .map(role -> { // make participant inactive in projects that do not match the new project - if (role.getAuthority().getName().equals(PARTICIPANT.authority()) + if (role.getAuthority().getName().equals(PARTICIPANT.getAuthority()) && !role.getProject().getProjectName().equals( subjectDto.getProject().getProjectName())) { return getProjectParticipantRole(role.getProject(), INACTIVE_PARTICIPANT); diff --git a/src/main/java/org/radarbase/management/service/UserService.java b/src/main/java/org/radarbase/management/service/UserService.java index df137afdf..f6d08c83c 100644 --- a/src/main/java/org/radarbase/management/service/UserService.java +++ b/src/main/java/org/radarbase/management/service/UserService.java @@ -1,14 +1,13 @@ package org.radarbase.management.service; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.repository.filters.UserFilter; +import org.radarbase.management.security.Constants; import org.radarbase.management.security.SecurityUtils; import org.radarbase.management.service.dto.RoleDTO; import org.radarbase.management.service.dto.UserDTO; @@ -36,10 +35,6 @@ import java.util.stream.Collectors; import static org.radarbase.auth.authorization.Permission.ROLE_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkGlobalPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganization; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganizationAndProject; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnProject; import static org.radarbase.auth.authorization.RoleAuthority.INACTIVE_PARTICIPANT; import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; import static org.radarbase.management.service.RoleService.getRoleAuthority; @@ -75,7 +70,7 @@ public class UserService { private ManagementPortalProperties managementPortalProperties; @Autowired - private RadarToken token; + private AuthService authService; /** * Activate a user with the given activation key. @@ -195,7 +190,7 @@ private Set getUserRoles(Set roleDtos, Set oldRoles) var roles = roleDtos.stream() .map(roleDto -> { RoleAuthority authority = getRoleAuthority(roleDto); - return switch (authority.scope()) { + return switch (authority.getScope()) { case GLOBAL -> roleService.getGlobalRole(authority); case ORGANIZATION -> roleService.getOrganizationRole(authority, roleDto.getOrganizationId()); @@ -227,22 +222,19 @@ private void checkAuthorityForRoleChange(Set roles, Set oldRoles) private void checkAuthorityForRoleChange(Role role) throws NotAuthorizedException { - switch (role.getRole().scope()) { - case GLOBAL -> checkGlobalPermission(token, ROLE_UPDATE); - case ORGANIZATION -> checkPermissionOnOrganization(token, ROLE_UPDATE, - role.getOrganization().getName()); - case PROJECT -> { - if (role.getProject().getOrganization() != null) { - checkPermissionOnOrganizationAndProject(token, ROLE_UPDATE, - role.getProject().getOrganization().getName(), - role.getProject().getProjectName()); - } else { - checkPermissionOnProject(token, ROLE_UPDATE, - role.getProject().getProjectName()); + authService.checkPermission(ROLE_UPDATE, e -> { + switch (role.getRole().getScope()) { + case GLOBAL -> { } + case ORGANIZATION -> e.organization(role.getOrganization().getName()); + case PROJECT -> { + if (role.getProject().getOrganization() != null) { + e.organization(role.getProject().getOrganization().getName()); + } + e.project(role.getProject().getProjectName()); } + default -> throw new IllegalStateException("Unknown authority scope."); } - default -> throw new IllegalStateException("Unknown authority scope."); - } + }); } /** @@ -282,23 +274,18 @@ public void updateUser(String userName, String firstName, String lastName, * Update all information for a specific user, and return the modified user. * * @param userDto user to update - * @param updateProperties should update the user properties * @return updated user */ @Transactional - public Optional updateUser( - UserDTO userDto, boolean updateProperties) throws NotAuthorizedException { + public Optional updateUser(UserDTO userDto) throws NotAuthorizedException { Optional userOpt = userRepository.findById(userDto.getId()); if (userOpt.isPresent()) { User user = userOpt.get(); - if (updateProperties) { - user.setLogin(userDto.getLogin()); - user.setFirstName(userDto.getFirstName()); - user.setLastName(userDto.getLastName()); - user.setEmail(userDto.getEmail()); - user.setActivated(userDto.isActivated()); - user.setLangKey(userDto.getLangKey()); - } + user.setFirstName(userDto.getFirstName()); + user.setLastName(userDto.getLastName()); + user.setEmail(userDto.getEmail()); + user.setActivated(userDto.isActivated()); + user.setLangKey(userDto.getLangKey()); Set managedRoles = user.getRoles(); Set oldRoles = Set.copyOf(managedRoles); managedRoles.clear(); @@ -389,7 +376,7 @@ public void removeNotActivatedUsers() { ZonedDateTime cutoff = ZonedDateTime.now().minus(Period.ofDays(3)); List authorities = Arrays.asList( - PARTICIPANT.authority(), INACTIVE_PARTICIPANT.authority()); + PARTICIPANT.getAuthority(), INACTIVE_PARTICIPANT.getAuthority()); userRepository.findAllByActivatedAndAuthoritiesNot(false, authorities).stream() .filter(user -> revisionService.getAuditInfo(user).getCreatedAt().isBefore(cutoff)) diff --git a/src/main/java/org/radarbase/management/service/dto/AuthorityDTO.java b/src/main/java/org/radarbase/management/service/dto/AuthorityDTO.java index 964e72f6a..c1d6d1b5c 100644 --- a/src/main/java/org/radarbase/management/service/dto/AuthorityDTO.java +++ b/src/main/java/org/radarbase/management/service/dto/AuthorityDTO.java @@ -11,8 +11,8 @@ public AuthorityDTO() { } public AuthorityDTO(RoleAuthority role) { - this.name = role.authority(); - this.scope = role.scope().name(); + this.name = role.getAuthority(); + this.scope = role.getScope().name(); } public String getName() { diff --git a/src/main/java/org/radarbase/management/service/dto/UserDTO.java b/src/main/java/org/radarbase/management/service/dto/UserDTO.java index c758ed30e..d63d05ad1 100644 --- a/src/main/java/org/radarbase/management/service/dto/UserDTO.java +++ b/src/main/java/org/radarbase/management/service/dto/UserDTO.java @@ -5,7 +5,7 @@ import javax.validation.constraints.Pattern; import javax.validation.constraints.Size; import org.hibernate.validator.constraints.Email; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; /** * A DTO representing a user, with his authorities. diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index bb90e289b..ec792508e 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -1,10 +1,13 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; +import org.radarbase.auth.authorization.Permission; +import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.auth.token.RadarToken; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.User; import org.radarbase.management.security.SessionRadarToken; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.PasswordService; import org.radarbase.management.service.UserService; @@ -58,12 +61,15 @@ public class AccountResource { @Autowired private ManagementPortalProperties managementPortalProperties; - @Autowired(required = false) - private RadarToken token; + @Autowired + private AuthService authService; @Autowired private PasswordService passwordService; + @Autowired(required = false) + private RadarToken token; + /** * GET /activate : activate the registered user. * @@ -87,7 +93,10 @@ public ResponseEntity activateAccount(@RequestParam(value = "key") Strin */ @PostMapping("/login") @Timed - public ResponseEntity login(HttpSession session) { + public ResponseEntity login(HttpSession session) throws NotAuthorizedException { + if (token == null) { + throw new NotAuthorizedException("Cannot login without credentials"); + } log.debug("Logging in user to session with principal {}", token.getUsername()); RadarToken sessionToken = new SessionRadarToken(token); session.setAttribute(TOKEN_ATTRIBUTE, sessionToken); @@ -136,12 +145,8 @@ public ResponseEntity getAccount() { @PostMapping("/account") @Timed public ResponseEntity saveAccount(@Valid @RequestBody UserDTO userDto, - Authentication authentication) { - if (authentication.getPrincipal() == null) { - throw new RadarWebApplicationException(HttpStatus.FORBIDDEN, - "Cannot update account without user", USER, ERR_ACCESS_DENIED); - } - + Authentication authentication) throws NotAuthorizedException { + authService.checkPermission(Permission.USER_UPDATE, e -> e.user(userDto.getLogin())); userService.updateUser(authentication.getName(), userDto.getFirstName(), userDto.getLastName(), userDto.getEmail(), userDto.getLangKey()); @@ -166,8 +171,8 @@ public ResponseEntity changePassword(@RequestBody String password) { /** - * POST /account/reset-activation/init : Send an email to resend the password activation - * for the the user. + * POST /account/reset-activation/init : Resend the password activation email + * to the user. * * @param login the login of the user * @return the ResponseEntity with status 200 (OK) if the email was sent, or status 400 (Bad @@ -187,7 +192,7 @@ public ResponseEntity requestActivationReset(@RequestBody String login) { } /** - * POST /account/reset_password/init : Send an email to reset the password of the user. + * POST /account/reset_password/init : Email the user a password reset link. * * @param mail the mail of the user * @return the ResponseEntity with status 200 (OK) if the email was sent, or status 400 (Bad diff --git a/src/main/java/org/radarbase/management/web/rest/AuditResource.java b/src/main/java/org/radarbase/management/web/rest/AuditResource.java index 697845908..bfb40b77e 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuditResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuditResource.java @@ -2,8 +2,8 @@ import io.swagger.v3.oas.annotations.Parameter; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.service.AuditEventService; +import org.radarbase.management.service.AuthService; import org.radarbase.management.web.rest.util.PaginationUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.audit.AuditEvent; @@ -23,7 +23,6 @@ import java.util.List; import static org.radarbase.auth.authorization.Permission.AUDIT_READ; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; /** * REST controller for getting the audit events. @@ -31,11 +30,10 @@ @RestController @RequestMapping("/management/audits") public class AuditResource { - @Autowired - private RadarToken token; - @Autowired private AuditEventService auditEventService; + @Autowired + private AuthService authService; /** * GET /audits : get a page of AuditEvents. @@ -46,7 +44,7 @@ public class AuditResource { @GetMapping public ResponseEntity> getAll(@Parameter Pageable pageable) throws NotAuthorizedException { - checkPermission(token, AUDIT_READ); + authService.checkPermission(AUDIT_READ); Page page = auditEventService.findAll(pageable); HttpHeaders headers = PaginationUtil .generatePaginationHttpHeaders(page, "/management/audits"); @@ -67,7 +65,7 @@ public ResponseEntity> getByDates( @RequestParam(value = "fromDate") LocalDate fromDate, @RequestParam(value = "toDate") LocalDate toDate, @Parameter Pageable pageable) throws NotAuthorizedException { - checkPermission(token, AUDIT_READ); + authService.checkPermission(AUDIT_READ); Page page = auditEventService .findByDates(fromDate.atTime(0, 0), toDate.atTime(23, 59), pageable); HttpHeaders headers = PaginationUtil @@ -84,7 +82,7 @@ public ResponseEntity> getByDates( */ @GetMapping("/{id:.+}") public ResponseEntity get(@PathVariable Long id) throws NotAuthorizedException { - checkPermission(token, AUDIT_READ); + authService.checkPermission(AUDIT_READ); return ResponseUtil.wrapOrNotFound(auditEventService.find(id)); } } diff --git a/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java b/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java index 40a29f6d3..768baf240 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java @@ -1,17 +1,9 @@ package org.radarbase.management.web.rest; -import static org.radarbase.auth.authorization.Permission.AUTHORITY_READ; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; - import io.micrometer.core.annotation.Timed; - -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - import org.radarbase.auth.authorization.RoleAuthority; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.dto.AuthorityDTO; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,16 +12,21 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.radarbase.auth.authorization.Permission.AUTHORITY_READ; + /** * REST controller for managing Authority. */ @RestController @RequestMapping("/api") public class AuthorityResource { - @Autowired - private RadarToken token; - private static final Logger log = LoggerFactory.getLogger(AuthorityResource.class); + @Autowired + private AuthService authService; /** * GET /authorities : get all the authorities. @@ -40,7 +37,7 @@ public class AuthorityResource { @Timed public List getAllAuthorities() throws NotAuthorizedException { log.debug("REST request to get all Authorities"); - checkPermission(token, AUTHORITY_READ); + authService.checkScope(AUTHORITY_READ); return Stream.of( RoleAuthority.SYS_ADMIN, RoleAuthority.ORGANIZATION_ADMIN, diff --git a/src/main/java/org/radarbase/management/web/rest/GroupResource.java b/src/main/java/org/radarbase/management/web/rest/GroupResource.java index b11eea90f..d46db4d5a 100644 --- a/src/main/java/org/radarbase/management/web/rest/GroupResource.java +++ b/src/main/java/org/radarbase/management/web/rest/GroupResource.java @@ -9,14 +9,11 @@ package org.radarbase.management.web.rest; -import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.GroupService; -import org.radarbase.management.service.ProjectService; import org.radarbase.management.service.dto.GroupDTO; -import org.radarbase.management.service.dto.ProjectDTO; import org.radarbase.management.web.rest.errors.BadRequestException; import org.radarbase.management.web.rest.vm.GroupPatchOperation; import org.springframework.beans.factory.annotation.Autowired; @@ -40,8 +37,6 @@ import static org.radarbase.auth.authorization.Permission.PROJECT_READ; import static org.radarbase.auth.authorization.Permission.PROJECT_UPDATE; import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganizationAndProject; import static org.radarbase.management.web.rest.errors.EntityName.GROUP; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_VALIDATION; @@ -52,10 +47,7 @@ public class GroupResource { private GroupService groupService; @Autowired - private RadarToken token; - - @Autowired - private ProjectService projectService; + private AuthService authService; /** * Create group. @@ -68,7 +60,7 @@ public class GroupResource { public ResponseEntity createGroup( @PathVariable String projectName, @Valid @RequestBody GroupDTO groupDto) throws NotAuthorizedException { - checkProjectPermission(PROJECT_UPDATE, projectName); + authService.checkPermission(PROJECT_UPDATE, e -> e.project(projectName)); GroupDTO groupDtoResult = groupService.createGroup(projectName, groupDto); URI location = MvcUriComponentsBuilder.fromController(getClass()) .path("/{groupName}") @@ -87,7 +79,7 @@ public ResponseEntity createGroup( @GetMapping public List listGroups( @PathVariable String projectName) throws NotAuthorizedException { - checkProjectPermission(PROJECT_READ, projectName); + authService.checkPermission(PROJECT_READ, e -> e.project(projectName)); return groupService.listGroups(projectName); } @@ -102,7 +94,7 @@ public List listGroups( public GroupDTO getGroup( @PathVariable String projectName, @PathVariable String groupName) throws NotAuthorizedException { - checkProjectPermission(PROJECT_READ, projectName); + authService.checkPermission(PROJECT_READ, e -> e.project(projectName)); return groupService.getGroup(projectName, groupName); } @@ -117,7 +109,7 @@ public ResponseEntity deleteGroup( @RequestParam(defaultValue = "false") Boolean unlinkSubjects, @PathVariable String projectName, @PathVariable String groupName) throws NotAuthorizedException { - checkProjectPermission(PROJECT_UPDATE, projectName); + authService.checkPermission(PROJECT_UPDATE, e -> e.project(projectName)); groupService.deleteGroup(projectName, groupName, unlinkSubjects); return ResponseEntity.noContent().build(); } @@ -138,7 +130,7 @@ public ResponseEntity changeGroupSubjects( // so it would make sense to check permissions per subject, // but I assume that only those who are authorized to perform project-wide actions // should be allowed to use this endpoint - checkProjectPermission(SUBJECT_UPDATE, projectName); + authService.checkPermission(SUBJECT_UPDATE, e -> e.project(projectName)); var addedItems = new ArrayList(); var removedItems = new ArrayList(); @@ -161,12 +153,4 @@ public ResponseEntity changeGroupSubjects( groupService.updateGroupSubjects(projectName, groupName, addedItems, removedItems); return ResponseEntity.noContent().build(); } - - private void checkProjectPermission(Permission permission, String projectName) - throws NotAuthorizedException { - checkPermission(token, permission); - ProjectDTO project = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, permission, - project.getOrganization().getName(), projectName); - } } diff --git a/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java b/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java index 9f8788387..c79cebe4a 100644 --- a/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java +++ b/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java @@ -2,11 +2,11 @@ import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; +import org.radarbase.management.security.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.MetaToken; import org.radarbase.management.domain.Subject; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MetaTokenService; import org.radarbase.management.service.dto.ClientPairInfoDTO; import org.radarbase.management.service.dto.TokenDTO; @@ -24,7 +24,6 @@ import java.time.Duration; import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnSubject; @RestController @RequestMapping("/api") @@ -38,6 +37,9 @@ public class MetaTokenResource { @Autowired private MetaTokenService metaTokenService; + @Autowired + private AuthService authService; + /** * GET /api/meta-token/:tokenName. * @@ -64,8 +66,7 @@ public ResponseEntity getTokenByTokenName(@PathVariable("tokenName") S */ @DeleteMapping("/meta-token/{tokenName:" + Constants.TOKEN_NAME_REGEX + "}") @Timed - public ResponseEntity deleteTokenByTokenName( - @PathVariable("tokenName") String tokenName, RadarToken token) + public ResponseEntity deleteTokenByTokenName(@PathVariable("tokenName") String tokenName) throws NotAuthorizedException { log.info("Requesting token with tokenName {}", tokenName); MetaToken metaToken = metaTokenService.getToken(tokenName); @@ -76,7 +77,7 @@ public ResponseEntity deleteTokenByTokenName( )) .getProjectName(); String user = subject.getUser().getLogin(); - checkPermissionOnSubject(token, SUBJECT_UPDATE, project, user); + authService.checkPermission(SUBJECT_UPDATE, e -> e.project(project).subject(user)); metaTokenService.delete(metaToken); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java index 9c5647600..6a1697155 100644 --- a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java @@ -1,27 +1,12 @@ package org.radarbase.management.web.rest; -import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_CREATE; -import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_DELETE; -import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_READ; -import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_UPDATE; -import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnSubject; -import static org.radarbase.management.service.OAuthClientService.checkProtected; -import static org.radarbase.management.web.rest.errors.EntityName.OAUTH_CLIENT; - -import java.net.MalformedURLException; -import java.net.URISyntaxException; -import java.util.List; -import javax.validation.Valid; - import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Subject; import org.radarbase.management.domain.User; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MetaTokenService; import org.radarbase.management.service.OAuthClientService; import org.radarbase.management.service.ResourceUriService; @@ -30,6 +15,7 @@ import org.radarbase.management.service.dto.ClientDetailsDTO; import org.radarbase.management.service.dto.ClientPairInfoDTO; import org.radarbase.management.service.mapper.ClientDetailsMapper; +import org.radarbase.management.web.rest.errors.NotFoundException; import org.radarbase.management.web.rest.util.HeaderUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,6 +36,21 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import javax.validation.Valid; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.util.List; + +import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_CREATE; +import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_DELETE; +import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_READ; +import static org.radarbase.auth.authorization.Permission.OAUTHCLIENTS_UPDATE; +import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; +import static org.radarbase.management.service.OAuthClientService.checkProtected; +import static org.radarbase.management.web.rest.errors.EntityName.OAUTH_CLIENT; +import static org.radarbase.management.web.rest.errors.EntityName.SUBJECT; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SUBJECT_NOT_FOUND; + /** * Created by dverbeec on 5/09/2017. */ @@ -79,7 +80,7 @@ public class OAuthClientsResource { private AuditEventRepository eventRepository; @Autowired - private RadarToken token; + private AuthService authService; /** * GET /api/oauth-clients. @@ -91,7 +92,7 @@ public class OAuthClientsResource { @GetMapping("/oauth-clients") @Timed public ResponseEntity> getOAuthClients() throws NotAuthorizedException { - checkPermission(token, OAUTHCLIENTS_READ); + authService.checkPermission(OAUTHCLIENTS_READ); return ResponseEntity.ok().body(clientDetailsMapper .clientDetailsToClientDetailsDTO(oAuthClientService.findAllOAuthClients())); } @@ -108,7 +109,7 @@ public ResponseEntity> getOAuthClients() throws NotAuthor @Timed public ResponseEntity getOAuthClientById(@PathVariable("id") String id) throws NotAuthorizedException { - checkPermission(token, OAUTHCLIENTS_READ); + authService.checkPermission(OAUTHCLIENTS_READ); // getOAuthClient checks if the id exists return ResponseEntity.ok().body(clientDetailsMapper .clientDetailsToClientDetailsDTO(oAuthClientService.findOneByClientId(id))); @@ -126,7 +127,7 @@ public ResponseEntity getOAuthClientById(@PathVariable("id") S @Timed public ResponseEntity updateOAuthClient(@Valid @RequestBody ClientDetailsDTO clientDetailsDto) throws NotAuthorizedException { - checkPermission(token, OAUTHCLIENTS_UPDATE); + authService.checkPermission(OAUTHCLIENTS_UPDATE); // getOAuthClient checks if the id exists checkProtected(oAuthClientService.findOneByClientId(clientDetailsDto.getClientId())); @@ -149,7 +150,7 @@ public ResponseEntity updateOAuthClient(@Valid @RequestBody Cl @Timed public ResponseEntity deleteOAuthClient(@PathVariable String id) throws NotAuthorizedException { - checkPermission(token, OAUTHCLIENTS_DELETE); + authService.checkPermission(OAUTHCLIENTS_DELETE); // getOAuthClient checks if the id exists checkProtected(oAuthClientService.findOneByClientId(id)); oAuthClientService.deleteClientDetails(id); @@ -170,7 +171,7 @@ public ResponseEntity deleteOAuthClient(@PathVariable String id) @Timed public ResponseEntity createOAuthClient(@Valid @RequestBody ClientDetailsDTO clientDetailsDto) throws URISyntaxException, NotAuthorizedException { - checkPermission(token, OAUTHCLIENTS_CREATE); + authService.checkPermission(OAUTHCLIENTS_CREATE); ClientDetails created = oAuthClientService.createClientDetail(clientDetailsDto); return ResponseEntity.created(ResourceUriService.getUri(clientDetailsDto)) .headers(HeaderUtil.createEntityCreationAlert(OAUTH_CLIENT, created.getClientId())) @@ -194,6 +195,7 @@ public ResponseEntity getRefreshToken(@RequestParam String lo @RequestParam(value = "clientId") String clientId, @RequestParam(value = "persistent", defaultValue = "false") Boolean persistent) throws NotAuthorizedException, URISyntaxException, MalformedURLException { + authService.checkScope(SUBJECT_UPDATE); User currentUser = userService.getUserWithAuthorities(); if (currentUser == null) { // We only allow this for actual logged in users for now, not for client_credentials @@ -204,10 +206,12 @@ public ResponseEntity getRefreshToken(@RequestParam String lo Subject subject = subjectService.findOneByLogin(login); String project = subject.getActiveProject() .map(Project::getProjectName) - .orElse(null); + .orElseThrow(() -> new NotFoundException( + "Project for subject " + login + " not found", SUBJECT, + ERR_SUBJECT_NOT_FOUND)); // Users who can update a subject can also generate a refresh token for that subject - checkPermissionOnSubject(token, SUBJECT_UPDATE, project, login); + authService.checkPermission(SUBJECT_UPDATE, e -> e.project(project).subject(login)); ClientPairInfoDTO cpi = metaTokenService.createMetaToken(subject, clientId, persistent); // generate audit event diff --git a/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java b/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java index 8af921f55..d60e11939 100644 --- a/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java @@ -1,15 +1,15 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.OrganizationService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.dto.OrganizationDTO; import org.radarbase.management.service.dto.ProjectDTO; -import org.radarbase.management.web.rest.errors.NotFoundException; import org.radarbase.management.web.rest.errors.ErrorConstants; +import org.radarbase.management.web.rest.errors.NotFoundException; import org.radarbase.management.web.rest.util.HeaderUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,8 +32,6 @@ import static org.radarbase.auth.authorization.Permission.ORGANIZATION_CREATE; import static org.radarbase.auth.authorization.Permission.ORGANIZATION_READ; import static org.radarbase.auth.authorization.Permission.ORGANIZATION_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganization; import static org.radarbase.auth.authorization.Permission.PROJECT_READ; import static org.radarbase.management.web.rest.errors.EntityName.ORGANIZATION; @@ -52,7 +50,7 @@ public class OrganizationResource { private OrganizationService organizationService; @Autowired - private RadarToken token; + private AuthService authService; /** * POST /organizations : Create a new organization. @@ -69,7 +67,7 @@ public ResponseEntity createOrganization( @Valid @RequestBody OrganizationDTO organizationDto ) throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save Organization : {}", organizationDto); - checkPermission(token, ORGANIZATION_CREATE); + authService.checkPermission(ORGANIZATION_CREATE); if (organizationDto.getId() != null) { var msg = "A new organization cannot already have an ID"; var headers = HeaderUtil.createFailureAlert(ENTITY_NAME, "idexists", msg); @@ -97,7 +95,7 @@ public ResponseEntity createOrganization( @Timed public ResponseEntity getAllOrganizations() throws NotAuthorizedException { log.debug("REST request to get Organizations"); - checkPermission(token, ORGANIZATION_READ); + authService.checkScope(ORGANIZATION_READ); var orgs = organizationService.findAll(); return new ResponseEntity<>(orgs, HttpStatus.OK); } @@ -122,7 +120,7 @@ public ResponseEntity updateOrganization( return createOrganization(organizationDto); } var name = organizationDto.getName(); - checkPermissionOnOrganization(token, ORGANIZATION_UPDATE, name); + authService.checkPermission(ORGANIZATION_UPDATE, e -> e.organization(name)); var result = organizationService.save(organizationDto); return ResponseEntity.ok() .headers(HeaderUtil.createEntityCreationAlert(ENTITY_NAME, result.getName())) @@ -142,7 +140,7 @@ public ResponseEntity updateOrganization( public ResponseEntity getOrganization( @PathVariable String name) throws NotAuthorizedException { log.debug("REST request to get Organization : {}", name); - checkPermissionOnOrganization(token, ORGANIZATION_READ, name); + authService.checkPermission(ORGANIZATION_READ, e -> e.organization(name)); var org = organizationService.findByName(name); var dto = org.orElseThrow(() -> new NotFoundException( "Organization not found with name " + name, @@ -165,7 +163,7 @@ public ResponseEntity getOrganization( public ResponseEntity> getOrganizationProjects( @PathVariable String name) throws NotAuthorizedException { log.debug("REST request to get Projects of the Organization : {}", name); - checkPermissionOnOrganization(token, PROJECT_READ, name); + authService.checkPermission(PROJECT_READ, e -> e.organization(name)); var projects = organizationService.findAllProjectsByOrganizationName(name); return ResponseEntity.ok(projects); } diff --git a/src/main/java/org/radarbase/management/web/rest/ProjectResource.java b/src/main/java/org/radarbase/management/web/rest/ProjectResource.java index 018c915b6..30b10ea84 100644 --- a/src/main/java/org/radarbase/management/web/rest/ProjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/ProjectResource.java @@ -2,10 +2,10 @@ import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.Parameter; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.repository.ProjectRepository; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ProjectService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.RoleService; @@ -46,7 +46,6 @@ import javax.validation.Valid; import java.net.URISyntaxException; import java.util.List; -import java.util.Objects; import static org.radarbase.auth.authorization.Permission.PROJECT_CREATE; import static org.radarbase.auth.authorization.Permission.PROJECT_DELETE; @@ -55,10 +54,6 @@ import static org.radarbase.auth.authorization.Permission.ROLE_READ; import static org.radarbase.auth.authorization.Permission.SOURCE_READ; import static org.radarbase.auth.authorization.Permission.SUBJECT_READ; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganization; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnOrganizationAndProject; -import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_PROJECT_NOT_EMPTY; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_VALIDATION; @@ -79,9 +74,6 @@ public class ProjectResource { @Autowired private ProjectService projectService; - @Autowired - private RadarToken token; - @Autowired private RoleService roleService; @@ -94,6 +86,9 @@ public class ProjectResource { @Autowired private SourceService sourceService; + @Autowired + private AuthService authService; + /** * POST /projects : Create a new project. * @@ -112,7 +107,7 @@ public ResponseEntity createProject(@Valid @RequestBody ProjectDTO p throw new BadRequestException("Organization must be provided", ENTITY_NAME, ERR_VALIDATION); } - checkPermissionOnOrganization(token, PROJECT_CREATE, org.getName()); + authService.checkPermission(PROJECT_CREATE, e -> e.organization(org.getName())); if (projectDto.getId() != null) { return ResponseEntity.badRequest() @@ -161,13 +156,14 @@ public ResponseEntity updateProject(@Valid @RequestBody ProjectDTO p // they must have permissions to modify both new & old organizations var newOrgName = org.getName(); var existingProject = projectService.findOne(projectDto.getId()); - checkPermissionOnOrganizationAndProject(token, PROJECT_UPDATE, newOrgName, - existingProject.getProjectName()); + authService.checkPermission(PROJECT_UPDATE, e -> e + .organization(newOrgName) + .project(existingProject.getProjectName())); var oldOrgName = existingProject.getOrganization().getName(); if (!newOrgName.equals(oldOrgName)) { - checkPermissionOnOrganization(token, PROJECT_UPDATE, oldOrgName); - checkPermissionOnOrganization(token, PROJECT_UPDATE, newOrgName); + authService.checkPermission(PROJECT_UPDATE, e -> e.organization(oldOrgName)); + authService.checkPermission(PROJECT_UPDATE, e -> e.organization(newOrgName)); } ProjectDTO result = projectService.save(projectDto); @@ -189,7 +185,7 @@ public ResponseEntity getAllProjects( @RequestParam(name = "minimized", required = false, defaultValue = "false") Boolean minimized) throws NotAuthorizedException { log.debug("REST request to get Projects"); - checkPermission(token, PROJECT_READ); + authService.checkPermission(PROJECT_READ); Page page = projectService.findAll(minimized, pageable); HttpHeaders headers = PaginationUtil .generatePaginationHttpHeaders(page, "/api/projects"); @@ -207,11 +203,12 @@ public ResponseEntity getAllProjects( @Timed public ResponseEntity getProject(@PathVariable String projectName) throws NotAuthorizedException { - checkPermission(token, PROJECT_READ); + authService.checkScope(PROJECT_READ); log.debug("REST request to get Project : {}", projectName); ProjectDTO projectDto = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, PROJECT_READ, - projectDto.getOrganization().getName(), projectDto.getProjectName()); + authService.checkPermission(PROJECT_READ, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); return ResponseEntity.ok(projectDto); } @@ -226,11 +223,12 @@ public ResponseEntity getProject(@PathVariable String projectName) @Timed public List getSourceTypesOfProject(@PathVariable String projectName) throws NotAuthorizedException { - checkPermission(token, PROJECT_READ); + authService.checkScope(PROJECT_READ); log.debug("REST request to get Project : {}", projectName); ProjectDTO projectDto = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, PROJECT_READ, - projectDto.getOrganization().getName(), projectDto.getProjectName()); + authService.checkPermission(PROJECT_READ, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); return projectService.findSourceTypesByProjectId(projectDto.getId()); } @@ -245,11 +243,12 @@ public List getSourceTypesOfProject(@PathVariable String projectN @Timed public ResponseEntity deleteProject(@PathVariable String projectName) throws NotAuthorizedException { - checkPermission(token, PROJECT_DELETE); + authService.checkScope(PROJECT_DELETE); log.debug("REST request to delete Project : {}", projectName); ProjectDTO projectDto = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, PROJECT_DELETE, - projectDto.getOrganization().getName(), projectDto.getProjectName()); + authService.checkPermission(PROJECT_DELETE, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); try { projectService.delete(projectDto.getId()); @@ -271,11 +270,12 @@ public ResponseEntity deleteProject(@PathVariable String projectName) @Timed public ResponseEntity> getRolesByProject(@PathVariable String projectName) throws NotAuthorizedException { - checkPermission(token, ROLE_READ); + authService.checkScope(ROLE_READ); log.debug("REST request to get all Roles for project {}", projectName); ProjectDTO projectDto = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, ROLE_READ, - projectDto.getOrganization().getName(), projectDto.getProjectName()); + authService.checkPermission(ROLE_READ, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); return ResponseEntity.ok(roleService.getRolesByProject(projectName)); } @@ -291,17 +291,14 @@ public ResponseEntity getAllSourcesForProject(@Parameter Pageable pageable, @RequestParam(value = "assigned", required = false) Boolean assigned, @RequestParam(name = "minimized", required = false, defaultValue = "false") Boolean minimized) throws NotAuthorizedException { - checkPermission(token, SOURCE_READ); + authService.checkScope(SOURCE_READ); log.debug("REST request to get all Sources"); ProjectDTO projectDto = projectService.findOneByName(projectName); - RadarToken jwt = token; - checkPermissionOnOrganizationAndProject(jwt, SOURCE_READ, - projectDto.getOrganization().getName(), projectDto.getProjectName()); - if (!jwt.isClientCredentials() && jwt.hasAuthority(PARTICIPANT)) { - throw new NotAuthorizedException("Cannot list all project sources as a participant."); - } + authService.checkPermission(SOURCE_READ, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); - if (Objects.nonNull(assigned)) { + if (assigned != null) { if (minimized) { return ResponseEntity.ok(sourceService .findAllMinimalSourceDetailsByProjectAndAssigned( @@ -339,15 +336,13 @@ public ResponseEntity getAllSourcesForProject(@Parameter Pageable pageable, public ResponseEntity> getAllSubjects( @Valid SubjectCriteria subjectCriteria ) throws NotAuthorizedException { - checkPermission(token, SUBJECT_READ); + authService.checkScope(SUBJECT_READ); String projectName = subjectCriteria.getProjectName(); // this checks if the project exists ProjectDTO projectDto = projectService.findOneByName(projectName); - checkPermissionOnOrganizationAndProject(token, SUBJECT_READ, - projectDto.getOrganization().getName(), projectName); - if (!token.isClientCredentials() && token.hasAuthority(PARTICIPANT)) { - throw new NotAuthorizedException("Cannot list all project subjects as a participant."); - } + authService.checkPermission(SUBJECT_READ, e -> e + .organization(projectDto.getOrganization().getName()) + .project(projectDto.getProjectName())); // this checks if the project exists projectService.findOneByName(projectName); diff --git a/src/main/java/org/radarbase/management/web/rest/RoleResource.java b/src/main/java/org/radarbase/management/web/rest/RoleResource.java index 5e45ad48e..2426231d6 100644 --- a/src/main/java/org/radarbase/management/web/rest/RoleResource.java +++ b/src/main/java/org/radarbase/management/web/rest/RoleResource.java @@ -2,9 +2,9 @@ import io.micrometer.core.annotation.Timed; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.RoleService; import org.radarbase.management.service.dto.RoleDTO; @@ -30,7 +30,6 @@ import static org.radarbase.auth.authorization.Permission.ROLE_CREATE; import static org.radarbase.auth.authorization.Permission.ROLE_READ; import static org.radarbase.auth.authorization.Permission.ROLE_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnProject; /** * REST controller for managing Role. @@ -43,11 +42,10 @@ public class RoleResource { private static final String ENTITY_NAME = "role"; - @Autowired - private RadarToken token; - @Autowired private RoleService roleService; + @Autowired + private AuthService authService; /** * POST /Roles : Create a new role. @@ -63,7 +61,9 @@ public class RoleResource { public ResponseEntity createRole(@Valid @RequestBody RoleDTO roleDto) throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save Role : {}", roleDto); - checkPermissionOnProject(token, ROLE_CREATE, roleDto.getProjectName()); + authService.checkPermission(ROLE_CREATE, e -> e + .organization(roleDto.getOrganizationName()) + .project(roleDto.getProjectName())); if (roleDto.getId() != null) { return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(ENTITY_NAME, "idexists", "A new role cannot already have an ID")).body(null); @@ -92,7 +92,9 @@ public ResponseEntity updateRole(@Valid @RequestBody RoleDTO roleDto) if (roleDto.getId() == null) { return createRole(roleDto); } - checkPermissionOnProject(token, ROLE_UPDATE, roleDto.getProjectName()); + authService.checkPermission(ROLE_UPDATE, e -> e + .organization(roleDto.getOrganizationName()) + .project(roleDto.getProjectName())); RoleDTO result = roleService.save(roleDto); return ResponseEntity.ok() .headers(HeaderUtil.createEntityUpdateAlert(ENTITY_NAME, displayName(roleDto))) @@ -106,9 +108,9 @@ public ResponseEntity updateRole(@Valid @RequestBody RoleDTO roleDto) */ @GetMapping("/roles") @Timed - @Secured({RoleAuthority.SYS_ADMIN_AUTHORITY}) - public List getAllRoles() { + public List getAllRoles() throws NotAuthorizedException { log.debug("REST request to get all Roles"); + authService.checkPermission(ROLE_READ); return roleService.findAll(); } @@ -139,7 +141,8 @@ public List getAllAdminRoles() { @Timed public ResponseEntity getRole(@PathVariable String projectName, @PathVariable String authorityName) throws NotAuthorizedException { - checkPermissionOnProject(token, ROLE_READ, projectName); + log.debug("REST request to get all Roles"); + authService.checkPermission(ROLE_READ, e -> e.project(projectName)); return ResponseUtil.wrapOrNotFound(roleService .findOneByProjectNameAndAuthorityName(projectName, authorityName)); } diff --git a/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java b/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java index 40ac7d7fd..72701f7d3 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java @@ -1,9 +1,9 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceDataService; import org.radarbase.management.service.dto.SourceDataDTO; @@ -35,13 +35,10 @@ import java.util.List; import java.util.Optional; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; import static org.radarbase.auth.authorization.Permission.SOURCEDATA_CREATE; import static org.radarbase.auth.authorization.Permission.SOURCEDATA_DELETE; import static org.radarbase.auth.authorization.Permission.SOURCEDATA_READ; import static org.radarbase.auth.authorization.Permission.SOURCEDATA_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkAuthorityAndPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_DATA; /** @@ -53,11 +50,10 @@ public class SourceDataResource { private static final Logger log = LoggerFactory.getLogger(SourceDataResource.class); - @Autowired - private RadarToken token; - @Autowired private SourceDataService sourceDataService; + @Autowired + private AuthService authService; /** * POST /source-data : Create a new sourceData. @@ -72,7 +68,7 @@ public class SourceDataResource { public ResponseEntity createSourceData(@Valid @RequestBody SourceDataDTO sourceDataDto) throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save SourceData : {}", sourceDataDto); - checkAuthorityAndPermission(token, SYS_ADMIN, SOURCEDATA_CREATE); + authService.checkPermission(SOURCEDATA_CREATE); if (sourceDataDto.getId() != null) { return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(SOURCE_DATA, "idexists", "A new sourceData cannot already have an ID")).build(); @@ -106,7 +102,7 @@ public ResponseEntity updateSourceData(@Valid @RequestBody Source if (sourceDataDto.getId() == null) { return createSourceData(sourceDataDto); } - checkAuthorityAndPermission(token, SYS_ADMIN, SOURCEDATA_UPDATE); + authService.checkPermission(SOURCEDATA_UPDATE); SourceDataDTO result = sourceDataService.save(sourceDataDto); return ResponseEntity.ok().headers(HeaderUtil .createEntityUpdateAlert(SOURCE_DATA, sourceDataDto.getSourceDataName())) @@ -125,7 +121,7 @@ public ResponseEntity> getAllSourceData( @PageableDefault(page = 0, size = Integer.MAX_VALUE) Pageable pageable) throws NotAuthorizedException { log.debug("REST request to get all SourceData"); - checkPermission(token, SOURCEDATA_READ); + authService.checkScope(SOURCEDATA_READ); Page page = sourceDataService.findAll(pageable); HttpHeaders headers = PaginationUtil .generatePaginationHttpHeaders(page, "/api/source-data"); @@ -143,7 +139,7 @@ public ResponseEntity> getAllSourceData( @Timed public ResponseEntity getSourceData(@PathVariable String sourceDataName) throws NotAuthorizedException { - checkPermission(token, SOURCEDATA_READ); + authService.checkScope(SOURCEDATA_READ); return ResponseUtil.wrapOrNotFound(sourceDataService .findOneBySourceDataName(sourceDataName)); } @@ -158,7 +154,7 @@ public ResponseEntity getSourceData(@PathVariable String sourceDa @Timed public ResponseEntity deleteSourceData(@PathVariable String sourceDataName) throws NotAuthorizedException { - checkPermission(token, SOURCEDATA_DELETE); + authService.checkPermission(SOURCEDATA_DELETE); Optional sourceDataDto = sourceDataService .findOneBySourceDataName(sourceDataName); if (sourceDataDto.isEmpty()) { diff --git a/src/main/java/org/radarbase/management/web/rest/SourceResource.java b/src/main/java/org/radarbase/management/web/rest/SourceResource.java index 5785d06c6..9fc1c4468 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceResource.java @@ -1,11 +1,11 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.SourceRepository; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceService; import org.radarbase.management.service.dto.MinimalProjectDetailsDTO; @@ -38,16 +38,11 @@ import java.util.Optional; import java.util.stream.Collectors; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; import static org.radarbase.auth.authorization.Permission.SOURCE_CREATE; import static org.radarbase.auth.authorization.Permission.SOURCE_DELETE; import static org.radarbase.auth.authorization.Permission.SOURCE_READ; import static org.radarbase.auth.authorization.Permission.SOURCE_UPDATE; import static org.radarbase.auth.authorization.Permission.SUBJECT_READ; -import static org.radarbase.auth.authorization.RadarAuthorization.checkAuthorityAndPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnProject; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnSource; import static tech.jhipster.web.util.ResponseUtil.wrapOrNotFound; /** @@ -68,7 +63,7 @@ public class SourceResource { private SourceRepository sourceRepository; @Autowired - private RadarToken token; + private AuthService authService; /** * POST /sources : Create a new source. @@ -84,8 +79,11 @@ public ResponseEntity createSource(@Valid @RequestBody SourceDTO sour throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save Source : {}", sourceDto); MinimalProjectDetailsDTO project = sourceDto.getProject(); - String projectName = project != null ? project.getProjectName() : null; - checkPermissionOnProject(token, SOURCE_CREATE, projectName); + authService.checkPermission(SOURCE_CREATE, e -> { + if (project != null) { + e.project(project.getProjectName()); + } + }); if (sourceDto.getId() != null) { return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(ENTITY_NAME, "idexists", "A new source cannot already have an ID")).build(); @@ -125,11 +123,13 @@ public ResponseEntity updateSource(@Valid @RequestBody SourceDTO sour if (sourceDto.getId() == null) { return createSource(sourceDto); } - RadarToken jwt = token; MinimalProjectDetailsDTO project = sourceDto.getProject(); - String projectName = project != null ? project.getProjectName() : null; - checkPermissionOnProject(jwt, SOURCE_UPDATE, projectName); - Optional updatedSource = sourceService.updateSource(sourceDto, jwt); + authService.checkPermission(SOURCE_UPDATE, e -> { + if (project != null) { + e.project(project.getProjectName()); + } + }); + Optional updatedSource = sourceService.updateSource(sourceDto); return wrapOrNotFound(updatedSource, HeaderUtil.createEntityUpdateAlert(ENTITY_NAME, sourceDto.getSourceName())); } @@ -144,7 +144,7 @@ public ResponseEntity updateSource(@Valid @RequestBody SourceDTO sour public ResponseEntity> getAllSources( @PageableDefault(page = 0, size = Integer.MAX_VALUE) Pageable pageable) throws NotAuthorizedException { - checkAuthorityAndPermission(token, SYS_ADMIN, SUBJECT_READ); + authService.checkPermission(SUBJECT_READ); log.debug("REST request to get all Sources"); Page page = sourceService.findAll(pageable); HttpHeaders headers = PaginationUtil @@ -164,15 +164,17 @@ public ResponseEntity> getAllSources( public ResponseEntity getSource(@PathVariable String sourceName) throws NotAuthorizedException { log.debug("REST request to get Source : {}", sourceName); - checkPermission(token, SOURCE_READ); + authService.checkScope(SOURCE_READ); Optional sourceOpt = sourceService.findOneByName(sourceName); if (sourceOpt.isPresent()) { SourceDTO source = sourceOpt.get(); - String projectName = source.getProject() != null - ? source.getProject().getProjectName() - : null; - checkPermissionOnSource(token, SOURCE_READ, projectName, source.getSubjectLogin(), - source.getSourceName()); + authService.checkPermission(SOURCE_READ, e -> { + if (source.getProject() != null) { + e.project(source.getProject().getProjectName()); + } + e.subject(source.getSubjectLogin()); + e.source(source.getSourceName()); + }); } return wrapOrNotFound(sourceOpt); } @@ -188,18 +190,19 @@ public ResponseEntity getSource(@PathVariable String sourceName) public ResponseEntity deleteSource(@PathVariable String sourceName) throws NotAuthorizedException { log.debug("REST request to delete Source : {}", sourceName); - RadarToken jwt = token; - checkPermission(jwt, SOURCE_DELETE); + authService.checkScope(SOURCE_DELETE); Optional sourceDtoOpt = sourceService.findOneByName(sourceName); if (sourceDtoOpt.isEmpty()) { return ResponseEntity.notFound().build(); } SourceDTO sourceDto = sourceDtoOpt.get(); - String projectName = sourceDto.getProject() != null - ? sourceDto.getProject().getProjectName() - : null; - checkPermissionOnSource(jwt, SOURCE_DELETE, projectName, sourceDto.getSubjectLogin(), - sourceDto.getSourceName()); + authService.checkPermission(SOURCE_DELETE, e -> { + if (sourceDto.getProject() != null) { + e.project(sourceDto.getProject().getProjectName()); + } + e.subject(sourceDto.getSubjectLogin()) + .source(sourceDto.getSourceName()); + }); if (sourceDto.getAssigned()) { return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(ENTITY_NAME, diff --git a/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java b/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java index 65f8ad303..6e64b54f7 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java @@ -1,11 +1,11 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.SourceTypeRepository; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.dto.ProjectDTO; @@ -48,7 +48,6 @@ import static org.radarbase.auth.authorization.Permission.SOURCETYPE_DELETE; import static org.radarbase.auth.authorization.Permission.SOURCETYPE_READ; import static org.radarbase.auth.authorization.Permission.SOURCETYPE_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_TYPE; /** @@ -59,14 +58,13 @@ public class SourceTypeResource { private static final Logger log = LoggerFactory.getLogger(SourceTypeResource.class); - @Autowired - private RadarToken token; - @Autowired private SourceTypeService sourceTypeService; @Autowired private SourceTypeRepository sourceTypeRepository; + @Autowired + private AuthService authService; /** * POST /source-types : Create a new sourceType. @@ -81,7 +79,7 @@ public class SourceTypeResource { public ResponseEntity createSourceType(@Valid @RequestBody SourceTypeDTO sourceTypeDto) throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save SourceType : {}", sourceTypeDto); - checkPermission(token, SOURCETYPE_CREATE); + authService.checkPermission(SOURCETYPE_CREATE); if (sourceTypeDto.getId() != null) { return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(SOURCE_TYPE, "idexists", "A new sourceType cannot already have an ID")).build(); @@ -125,7 +123,7 @@ public ResponseEntity updateSourceType(@Valid @RequestBody if (sourceTypeDto.getId() == null) { return createSourceType(sourceTypeDto); } - checkPermission(token, SOURCETYPE_UPDATE); + authService.checkPermission(SOURCETYPE_UPDATE); SourceTypeDTO result = sourceTypeService.save(sourceTypeDto); return ResponseEntity.ok() .headers( @@ -144,7 +142,7 @@ public ResponseEntity updateSourceType(@Valid @RequestBody public ResponseEntity> getAllSourceTypes( @PageableDefault(page = 0, size = Integer.MAX_VALUE) Pageable pageable) throws NotAuthorizedException { - checkPermission(token, SOURCETYPE_READ); + authService.checkPermission(SOURCETYPE_READ); Page page = sourceTypeService.findAll(pageable); HttpHeaders headers = PaginationUtil .generatePaginationHttpHeaders(page, "/api/source-types"); @@ -161,7 +159,7 @@ public ResponseEntity> getAllSourceTypes( @Timed public ResponseEntity> getSourceTypes(@PathVariable String producer) throws NotAuthorizedException { - checkPermission(token, SOURCETYPE_READ); + authService.checkPermission(SOURCETYPE_READ); return ResponseEntity.ok(sourceTypeService.findByProducer(producer)); } @@ -178,7 +176,7 @@ public ResponseEntity> getSourceTypes(@PathVariable String p @Timed public ResponseEntity> getSourceTypes(@PathVariable String producer, @PathVariable String model) throws NotAuthorizedException { - checkPermission(token, SOURCETYPE_READ); + authService.checkPermission(SOURCETYPE_READ); return ResponseEntity.ok(sourceTypeService.findByProducerAndModel(producer, model)); } @@ -196,7 +194,7 @@ public ResponseEntity> getSourceTypes(@PathVariable String p public ResponseEntity getSourceTypes(@PathVariable String producer, @PathVariable String model, @PathVariable String version) throws NotAuthorizedException { - checkPermission(token, SOURCETYPE_READ); + authService.checkPermission(SOURCETYPE_READ); return ResponseUtil.wrapOrNotFound(Optional.ofNullable( sourceTypeService.findByProducerAndModelAndVersion(producer, model, version))); } @@ -216,7 +214,7 @@ public ResponseEntity getSourceTypes(@PathVariable String produce public ResponseEntity deleteSourceType(@PathVariable String producer, @PathVariable String model, @PathVariable String version) throws NotAuthorizedException { - checkPermission(token, SOURCETYPE_DELETE); + authService.checkPermission(SOURCETYPE_DELETE); SourceTypeDTO sourceTypeDto = sourceTypeService .findByProducerAndModelAndVersion(producer, model, version); if (Objects.isNull(sourceTypeDto)) { diff --git a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java index ecf63d749..65e2a899b 100644 --- a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java @@ -1,48 +1,19 @@ package org.radarbase.management.web.rest; -import static tech.jhipster.web.util.ResponseUtil.wrapOrNotFound; -import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; -import static org.radarbase.auth.authorization.Permission.SUBJECT_CREATE; -import static org.radarbase.auth.authorization.Permission.SUBJECT_DELETE; -import static org.radarbase.auth.authorization.Permission.SUBJECT_READ; -import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnProject; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermissionOnSubject; -import static org.radarbase.management.web.rest.errors.EntityName.SOURCE; -import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_TYPE; -import static org.radarbase.management.web.rest.errors.EntityName.SUBJECT; -import static org.radarbase.management.web.rest.errors.EntityName.PROJECT; -import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_ACTIVE_PARTICIPANT_PROJECT_NOT_FOUND; -import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SOURCE_TYPE_NOT_PROVIDED; -import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SUBJECT_NOT_FOUND; -import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_VALIDATION; - -import java.net.URISyntaxException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.validation.Valid; - import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Source; import org.radarbase.management.domain.SourceType; import org.radarbase.management.domain.Subject; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.repository.SubjectRepository; +import org.radarbase.management.security.Constants; import org.radarbase.management.security.SecurityUtils; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.RevisionService; import org.radarbase.management.service.SourceService; @@ -78,6 +49,32 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import javax.validation.Valid; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.radarbase.auth.authorization.Permission.SUBJECT_CREATE; +import static org.radarbase.auth.authorization.Permission.SUBJECT_DELETE; +import static org.radarbase.auth.authorization.Permission.SUBJECT_READ; +import static org.radarbase.auth.authorization.Permission.SUBJECT_UPDATE; +import static org.radarbase.management.web.rest.errors.EntityName.PROJECT; +import static org.radarbase.management.web.rest.errors.EntityName.SOURCE; +import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_TYPE; +import static org.radarbase.management.web.rest.errors.EntityName.SUBJECT; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_ACTIVE_PARTICIPANT_PROJECT_NOT_FOUND; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SOURCE_TYPE_NOT_PROVIDED; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SUBJECT_NOT_FOUND; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_VALIDATION; +import static tech.jhipster.web.util.ResponseUtil.wrapOrNotFound; + /** * REST controller for managing Subject. */ @@ -87,9 +84,6 @@ public class SubjectResource { private static final Logger log = LoggerFactory.getLogger(SubjectResource.class); - @Autowired - private RadarToken token; - @Autowired private SubjectService subjectService; @@ -113,6 +107,8 @@ public class SubjectResource { @Autowired private SourceService sourceService; + @Autowired + private AuthService authService; /** * POST /subjects : Create a new subject. @@ -132,8 +128,8 @@ public ResponseEntity createSubject(@RequestBody SubjectDTO subjectD .createFailureAlert(SUBJECT, "projectrequired", "A subject should be assigned to a project")).build(); } - checkPermissionOnProject(token, SUBJECT_CREATE, - subjectDto.getProject().getProjectName()); + authService.checkPermission(SUBJECT_CREATE, e -> e + .project(subjectDto.getProject().getProjectName())); if (subjectDto.getId() != null) { return ResponseEntity.badRequest().headers(HeaderUtil @@ -184,8 +180,10 @@ public ResponseEntity updateSubject(@RequestBody SubjectDTO subjectD if (subjectDto.getId() == null) { return createSubject(subjectDto); } - checkPermissionOnSubject(token, SUBJECT_UPDATE, - subjectDto.getProject().getProjectName(), subjectDto.getLogin()); + authService.checkPermission(SUBJECT_UPDATE, e -> e + .project(subjectDto.getProject().getProjectName()) + .subject(subjectDto.getLogin())); + SubjectDTO result = subjectService.updateSubject(subjectDto); return ResponseEntity.ok() .headers(HeaderUtil.createEntityUpdateAlert(SUBJECT, subjectDto.getLogin())) @@ -217,8 +215,8 @@ public ResponseEntity discontinueSubject(@RequestBody SubjectDTO sub .createFailureAlert(SUBJECT, "projectrequired", "A subject should be assigned to a project")).body(null); } - checkPermissionOnSubject(token, SUBJECT_UPDATE, - subjectDto.getProject().getProjectName(), subjectDto.getLogin()); + authService.checkPermission(SUBJECT_UPDATE, e -> e + .project(subjectDto.getProject().getProjectName()).subject(subjectDto.getLogin())); // In principle this is already captured by the PostUpdate event listener, adding this // event just makes it more clear a subject was discontinued. @@ -242,10 +240,7 @@ public ResponseEntity> getAllSubjects( @Valid SubjectCriteria subjectCriteria ) throws NotAuthorizedException { String projectName = subjectCriteria.getProjectName(); - checkPermissionOnProject(token, SUBJECT_READ, projectName); - if (!token.isClientCredentials() && token.hasAuthority(PARTICIPANT)) { - throw new NotAuthorizedException("Cannot list subjects as a participant."); - } + authService.checkPermission(SUBJECT_READ, e -> e.project(projectName)); String externalId = subjectCriteria.getExternalId(); log.debug("ProjectName {} and external {}", projectName, externalId); @@ -296,9 +291,12 @@ public ResponseEntity getSubject(@PathVariable String login) .flatMap(p -> projectRepository.findOneWithEagerRelationships(p.getId())) .orElse(null); - String projectName = project != null ? project.getProjectName() : null; - checkPermissionOnSubject(token, SUBJECT_READ, projectName, - subject.getUser().getLogin()); + authService.checkPermission(SUBJECT_READ, e -> { + if (project != null) { + e.project(project.getProjectName()); + } + e.subject(subject.getUser().getLogin()); + }); SubjectDTO subjectDto = subjectMapper.subjectToSubjectDTO(subject); @@ -327,8 +325,7 @@ public ResponseEntity> getSubjectRevisions( PROJECT, ERR_ACTIVE_PARTICIPANT_PROJECT_NOT_FOUND)); - checkPermissionOnSubject(token, SUBJECT_READ, project, - login); + authService.checkPermission(SUBJECT_READ, e -> e.project(project).subject(login)); Page page = revisionService.getRevisionsForEntity(pageable, subject); return ResponseEntity.ok() @@ -352,8 +349,9 @@ public ResponseEntity getSubjectRevision(@PathVariable String login, @PathVariable Integer revisionNb) throws NotAuthorizedException { log.debug("REST request to get Subject : {}, for revisionNb: {}", login, revisionNb); SubjectDTO subjectDto = subjectService.findRevision(login, revisionNb); - checkPermissionOnSubject(token, SUBJECT_READ, subjectDto.getProject() - .getProjectName(), subjectDto.getLogin()); + authService.checkPermission(SUBJECT_READ, e -> e + .project(subjectDto.getProject().getProjectName()) + .subject(subjectDto.getLogin())); return ResponseEntity.ok(subjectDto); } @@ -370,8 +368,12 @@ public ResponseEntity deleteSubject(@PathVariable String login) log.debug("REST request to delete Subject : {}", login); Subject subject = subjectService.findOneByLogin(login); - String projectName = subject.getActiveProject().map(Project::getProjectName).orElse(null); - checkPermissionOnSubject(token, SUBJECT_DELETE, projectName, login); + authService.checkPermission(SUBJECT_DELETE, e -> { + subject.getActiveProject() + .map(Project::getProjectName) + .ifPresent(e::project); + e.subject(subject.getUser().getLogin()); + }); subjectService.deleteSubject(login); return ResponseEntity.ok() .headers(HeaderUtil.createEntityDeletionAlert(SUBJECT, login)).build(); @@ -408,6 +410,7 @@ public ResponseEntity deleteSubject(@PathVariable String login) public ResponseEntity assignSources(@PathVariable String login, @RequestBody MinimalSourceDetailsDTO sourceDto) throws URISyntaxException, NotAuthorizedException { + authService.checkScope(SUBJECT_UPDATE); // find out source type id of supplied source Long sourceTypeId = sourceDto.getSourceTypeId(); @@ -448,8 +451,9 @@ public ResponseEntity assignSources(@PathVariable Strin SUBJECT, ERR_SOURCE_TYPE_NOT_PROVIDED) ); - checkPermissionOnSubject(token, SUBJECT_UPDATE, currentProject.getProjectName(), - sub.getUser().getLogin()); + authService.checkPermission(SUBJECT_UPDATE, e -> e + .project(currentProject.getProjectName()) + .subject(sub.getUser().getLogin())); // check if any of id, sourceID, sourceName were non-null boolean existing = Stream.of(sourceDto.getId(), sourceDto.getSourceName(), @@ -487,16 +491,19 @@ public ResponseEntity> getSubjectSources( @RequestParam(value = "withInactiveSources", required = false) Boolean withInactiveSourcesParam) throws NotAuthorizedException { + authService.checkScope(SUBJECT_READ); + boolean withInactiveSources = withInactiveSourcesParam != null && withInactiveSourcesParam; // check the subject id Subject subject = subjectRepository.findOneWithEagerBySubjectLogin(login) .orElseThrow(NoSuchElementException::new); - String projectName = subject.getActiveProject() - .map(Project::getProjectName) - .orElse(null); - - checkPermissionOnSubject(token, SUBJECT_READ, projectName, login); + authService.checkPermission(SUBJECT_READ, e -> { + subject.getActiveProject() + .map(Project::getProjectName) + .ifPresent(e::project); + e.subject(login); + }); if (withInactiveSources) { return ResponseEntity.ok(subjectService.findSubjectSourcesFromRevisions(subject)); @@ -535,16 +542,20 @@ public ResponseEntity> getSubjectSources( public ResponseEntity updateSubjectSource(@PathVariable String login, @PathVariable String sourceName, @RequestBody Map attributes) throws NotFoundException, NotAuthorizedException { + authService.checkScope(SUBJECT_UPDATE); + // check the subject id Subject subject = subjectRepository.findOneWithEagerBySubjectLogin(login) .orElseThrow(() -> new NotFoundException("Subject ID not found", SUBJECT, ERR_SUBJECT_NOT_FOUND, Collections.singletonMap("subjectLogin", login))); - String projectName = subject.getActiveProject() - .map(Project::getProjectName) - .orElse(null); - checkPermissionOnSubject(token, SUBJECT_UPDATE, projectName, login); + authService.checkPermission(SUBJECT_UPDATE, e -> { + subject.getActiveProject() + .map(Project::getProjectName) + .ifPresent(e::project); + e.subject(login); + }); // find source under subject Source source = subject.getSources().stream() diff --git a/src/main/java/org/radarbase/management/web/rest/TokenKeyEndpoint.java b/src/main/java/org/radarbase/management/web/rest/TokenKeyEndpoint.java index 605034642..fed32de5c 100644 --- a/src/main/java/org/radarbase/management/web/rest/TokenKeyEndpoint.java +++ b/src/main/java/org/radarbase/management/web/rest/TokenKeyEndpoint.java @@ -1,7 +1,7 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.security.jwk.JavaWebKeySet; +import org.radarbase.auth.jwks.JsonWebKeySet; import org.radarbase.management.security.jwt.ManagementPortalOauthKeyStoreHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,7 +30,7 @@ public TokenKeyEndpoint( */ @GetMapping("/oauth/token_key") @Timed - public JavaWebKeySet getKey() { + public JsonWebKeySet getKey() { logger.debug("Requesting verifier public keys..."); return keyStoreHandler.loadJwks(); } diff --git a/src/main/java/org/radarbase/management/web/rest/UserResource.java b/src/main/java/org/radarbase/management/web/rest/UserResource.java index b0f277622..1899089b4 100644 --- a/src/main/java/org/radarbase/management/web/rest/UserResource.java +++ b/src/main/java/org/radarbase/management/web/rest/UserResource.java @@ -1,15 +1,15 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Subject; import org.radarbase.management.domain.User; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.repository.filters.UserFilter; +import org.radarbase.management.security.Constants; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.UserService; @@ -50,8 +50,6 @@ import static org.radarbase.auth.authorization.Permission.USER_DELETE; import static org.radarbase.auth.authorization.Permission.USER_READ; import static org.radarbase.auth.authorization.Permission.USER_UPDATE; -import static org.radarbase.auth.authorization.RadarAuthorization.checkPermission; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; import static org.radarbase.management.web.rest.errors.EntityName.USER; /** @@ -97,11 +95,10 @@ public class UserResource { @Autowired private SubjectRepository subjectRepository; - @Autowired - private RadarToken token; - @Autowired private ManagementPortalProperties managementPortalProperties; + @Autowired + private AuthService authService; /** * POST /users : Creates a new user.

Creates a new user if the login and email are not @@ -118,7 +115,7 @@ public class UserResource { public ResponseEntity createUser(@RequestBody ManagedUserVM managedUserVm) throws URISyntaxException, NotAuthorizedException { log.debug("REST request to save User : {}", managedUserVm); - checkPermission(token, USER_CREATE); + authService.checkPermission(USER_CREATE); if (managedUserVm.getId() != null) { return ResponseEntity.badRequest() .headers(HeaderUtil.createFailureAlert(USER, "idexists", @@ -161,7 +158,7 @@ public ResponseEntity createUser(@RequestBody ManagedUserVM managedUserVm) public ResponseEntity updateUser(@RequestBody ManagedUserVM managedUserVm) throws NotAuthorizedException { log.debug("REST request to update User : {}", managedUserVm); - checkPermission(token, USER_UPDATE); + authService.checkPermission(USER_UPDATE, e -> e.user(managedUserVm.getLogin())); Optional existingUser = userRepository.findOneByEmail(managedUserVm.getEmail()); if (existingUser.isPresent() && (!existingUser.get().getId() .equals(managedUserVm.getId()))) { @@ -188,8 +185,7 @@ public ResponseEntity updateUser(@RequestBody ManagedUserVM managedUser } Optional updatedUser = userService.updateUser( - managedUserVm, - token.hasAuthority(SYS_ADMIN)); + managedUserVm); return ResponseUtil.wrapOrNotFound(updatedUser, HeaderUtil.createAlert("userManagement.updated", managedUserVm.getLogin())); @@ -213,7 +209,7 @@ public ResponseEntity> getUsers( UserFilter userFilter, @RequestParam(defaultValue = "true") boolean includeProvenance) throws NotAuthorizedException { - checkPermission(token, USER_READ); + authService.checkPermission(USER_READ); Page page = userService.findUsers(userFilter, pageable, includeProvenance); @@ -233,7 +229,7 @@ public ResponseEntity> getUsers( public ResponseEntity getUser(@PathVariable String login) throws NotAuthorizedException { log.debug("REST request to get User : {}", login); - checkPermission(token, USER_READ); + authService.checkPermission(USER_READ, e -> e.user(login)); return ResponseUtil.wrapOrNotFound( userService.getUserWithAuthoritiesByLogin(login)); } @@ -249,7 +245,7 @@ public ResponseEntity getUser(@PathVariable String login) public ResponseEntity deleteUser(@PathVariable String login) throws NotAuthorizedException { log.debug("REST request to delete User: {}", login); - checkPermission(token, USER_DELETE); + authService.checkPermission(USER_DELETE, e -> e.user(login)); userService.deleteUser(login); return ResponseEntity.ok().headers(HeaderUtil.createAlert("userManagement.deleted", login)) .build(); @@ -266,7 +262,7 @@ public ResponseEntity deleteUser(@PathVariable String login) public ResponseEntity> getUserRoles(@PathVariable String login) throws NotAuthorizedException { log.debug("REST request to read User roles: {}", login); - checkPermission(token, ROLE_READ); + authService.checkPermission(ROLE_READ, e -> e.user(login)); return ResponseUtil.wrapOrNotFound(userService.getUserWithAuthoritiesByLogin(login) .map(UserDTO::getRoles)); } @@ -282,7 +278,7 @@ public ResponseEntity> getUserRoles(@PathVariable String login) public ResponseEntity putUserRoles(@PathVariable String login, @RequestBody Set roleDtos) throws NotAuthorizedException { log.debug("REST request to update User roles: {} to {}", login, roleDtos); - checkPermission(token, ROLE_UPDATE); + authService.checkPermission(ROLE_UPDATE, e -> e.user(login)); userService.updateRoles(login, roleDtos); return ResponseEntity.noContent().build(); } diff --git a/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java b/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java index cb30ac389..60367b048 100644 --- a/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java +++ b/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java @@ -1,10 +1,8 @@ package org.radarbase.auth.authentication; import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.config.TokenValidatorConfig; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.User; @@ -15,16 +13,13 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.io.InputStream; -import java.net.URI; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; -import java.time.Duration; import java.time.Instant; -import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Optional; @@ -35,6 +30,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.radarbase.auth.jwks.JwksTokenVerifierLoader.toTokenVerifier; import static org.radarbase.management.security.jwt.ManagementPortalJwtAccessTokenConverter.RES_MANAGEMENT_PORTAL; /** @@ -57,7 +53,7 @@ public final class OAuthHelper { public static final String USER = "admin"; public static final String ISS = "RADAR"; public static final String JTI = "some-jwt-id"; - private static List verifiers; + private static List verifiers; static { try { @@ -126,9 +122,11 @@ public static void setUp() throws Exception { Algorithm rsa = Algorithm.RSA256(rsaPublicKey, rsaPrivateKey); validRsaToken = createValidToken(rsa); - verifiers = Stream.of(ecdsa, rsa) - .map(alg -> JWT.require(alg).withIssuer(ISS).build()) + var verifierList = Stream.of(ecdsa, rsa) + .map(alg -> toTokenVerifier(alg, RES_MANAGEMENT_PORTAL)) .collect(Collectors.toList()); + + verifiers = List.of(new StaticTokenVerifierLoader(verifierList)); } } @@ -150,25 +148,7 @@ public static JwtAuthenticationFilter createAuthenticationFilter() { */ public static TokenValidator createTokenValidator() { // Use tokenValidator with known JWTVerifier which signs. - return new TokenValidator.Builder() - .verifiers(verifiers) - .config(getDummyValidatorConfig()) - .fetchTimeout(Duration.ofHours(1)) - .build(); - } - - private static TokenValidatorConfig getDummyValidatorConfig() { - return new TokenValidatorConfig() { - @Override - public List getPublicKeyEndpoints() { - return Collections.emptyList(); - } - - @Override - public String getResourceName() { - return "ISS"; - } - }; + return new TokenValidator(verifiers); } private static User createAdminUser() { diff --git a/src/test/java/org/radarbase/management/config/MockConfiguration.java b/src/test/java/org/radarbase/management/config/MockConfiguration.java index 066aa8f04..2a4d770db 100644 --- a/src/test/java/org/radarbase/management/config/MockConfiguration.java +++ b/src/test/java/org/radarbase/management/config/MockConfiguration.java @@ -17,14 +17,12 @@ import org.springframework.context.annotation.Primary; import java.util.Arrays; -import java.util.List; +import java.util.LinkedHashSet; import java.util.Set; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN_AUTHORITY; @Configuration public class MockConfiguration { @@ -34,19 +32,9 @@ public RadarToken radarTokenMock() { RadarToken token = mock(RadarToken.class); when(token.getSubject()).thenReturn("admin"); when(token.getUsername()).thenReturn("admin"); - when(token.hasAuthority(any())).thenAnswer(a -> a.getArgument(0).equals(SYS_ADMIN)); - when(token.hasPermission(any())).thenReturn(true); - when(token.hasGlobalPermission(any())).thenReturn(true); - when(token.hasPermissionOnOrganization(any(), any())).thenReturn(true); - when(token.hasPermissionOnOrganizationAndProject(any(), any(), any())).thenReturn(true); - when(token.hasPermissionOnProject(any(), any())).thenReturn(true); - when(token.hasPermissionOnSubject(any(), any(), any())).thenReturn(true); - when(token.hasPermissionOnSource(any(), any(), any(), any())).thenReturn(true); when(token.isClientCredentials()).thenReturn(false); - when(token.getAuthorities()).thenReturn(List.of(SYS_ADMIN_AUTHORITY)); when(token.getRoles()).thenReturn(Set.of(new AuthorityReference(SYS_ADMIN))); - when(token.getScopes()).thenReturn(Arrays.asList(Permission.scopes())); - when(token.hasGlobalAuthorityForPermission(any())).thenReturn(true); + when(token.getScopes()).thenReturn(new LinkedHashSet<>(Arrays.asList(Permission.scopes()))); return token; } } diff --git a/src/test/java/org/radarbase/management/security/JwtAuthenticationFilterIntTest.java b/src/test/java/org/radarbase/management/security/JwtAuthenticationFilterIntTest.java index 1cd1df107..9e8e39d56 100644 --- a/src/test/java/org/radarbase/management/security/JwtAuthenticationFilterIntTest.java +++ b/src/test/java/org/radarbase/management/security/JwtAuthenticationFilterIntTest.java @@ -1,13 +1,13 @@ package org.radarbase.management.security; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ProjectService; -import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.web.rest.ProjectResource; import org.radarbase.management.web.rest.errors.ExceptionTranslator; import org.springframework.beans.factory.annotation.Autowired; @@ -36,9 +36,6 @@ @WithMockUser class JwtAuthenticationFilterIntTest { - @Autowired - private RadarToken radarToken; - @Autowired private ProjectService projectService; @@ -54,13 +51,15 @@ class JwtAuthenticationFilterIntTest { private MockMvc rsaRestProjectMockMvc; private MockMvc ecRestProjectMockMvc; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); ProjectResource projectResource = new ProjectResource(); ReflectionTestUtils.setField(projectResource, "projectService", projectService); - ReflectionTestUtils.setField(projectResource, "token", radarToken); + ReflectionTestUtils.setField(projectResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); @@ -70,14 +69,18 @@ public void setUp() throws ServletException { .setControllerAdvice(exceptionTranslator) .setMessageConverters(jacksonMessageConverter) .addFilter(filter) - .defaultRequest(get("/").with(OAuthHelper.rsaBearerToken())).build(); + .defaultRequest(get("/") + .with(OAuthHelper.rsaBearerToken())) + .build(); this.ecRestProjectMockMvc = MockMvcBuilders.standaloneSetup(projectResource) .setCustomArgumentResolvers(pageableArgumentResolver) .setControllerAdvice(exceptionTranslator) .setMessageConverters(jacksonMessageConverter) .addFilter(filter) - .defaultRequest(get("/").with(OAuthHelper.bearerToken())).build(); + .defaultRequest(get("/") + .with(OAuthHelper.bearerToken())) + .build(); } @Test diff --git a/src/test/java/org/radarbase/management/service/MetaTokenServiceTest.java b/src/test/java/org/radarbase/management/service/MetaTokenServiceTest.java index b91323326..bc39cd18f 100644 --- a/src/test/java/org/radarbase/management/service/MetaTokenServiceTest.java +++ b/src/test/java/org/radarbase/management/service/MetaTokenServiceTest.java @@ -1,16 +1,5 @@ package org.radarbase.management.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.net.MalformedURLException; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; -import java.util.List; - import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,6 +17,17 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; +import java.net.MalformedURLException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + /** * Test class for the MetaTokenService class. * diff --git a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java index cf98f5989..01ff482f8 100644 --- a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java +++ b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java @@ -1,11 +1,11 @@ package org.radarbase.management.service; +import org.radarbase.management.service.dto.ClientDetailsDTO; + import java.util.Arrays; import java.util.HashMap; import java.util.stream.Collectors; -import org.radarbase.management.service.dto.ClientDetailsDTO; - /** * Test class for the OAuthClientService class. * diff --git a/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.java b/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.java index e1e48f841..97dff1eee 100644 --- a/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.java +++ b/src/test/java/org/radarbase/management/service/RedcapIntegrationWorkFlowOnServiceLevelTest.java @@ -1,11 +1,5 @@ package org.radarbase.management.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.radarbase.management.ManagementPortalTestApp; @@ -17,6 +11,13 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + /** * Created by nivethika on 31-8-17. */ diff --git a/src/test/java/org/radarbase/management/service/SubjectServiceTest.java b/src/test/java/org/radarbase/management/service/SubjectServiceTest.java index 4037baf72..b9d6a7aaf 100644 --- a/src/test/java/org/radarbase/management/service/SubjectServiceTest.java +++ b/src/test/java/org/radarbase/management/service/SubjectServiceTest.java @@ -1,13 +1,5 @@ package org.radarbase.management.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.radarbase.management.service.dto.ProjectDTO.PRIVACY_POLICY_URL; -import static org.radarbase.management.service.dto.SubjectDTO.SubjectStatus.ACTIVATED; - -import java.net.URL; -import java.util.Collections; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.radarbase.management.ManagementPortalTestApp; @@ -19,6 +11,14 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; +import java.net.URL; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.radarbase.management.service.dto.ProjectDTO.PRIVACY_POLICY_URL; +import static org.radarbase.management.service.dto.SubjectDTO.SubjectStatus.ACTIVATED; + /** * Test class for the SubjectService class. * diff --git a/src/test/java/org/radarbase/management/service/UserServiceIntTest.java b/src/test/java/org/radarbase/management/service/UserServiceIntTest.java index 69b58bae3..780a6909b 100644 --- a/src/test/java/org/radarbase/management/service/UserServiceIntTest.java +++ b/src/test/java/org/radarbase/management/service/UserServiceIntTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.radarbase.auth.config.Constants; import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Authority; @@ -16,6 +15,7 @@ import org.radarbase.management.repository.CustomRevisionEntityRepository; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.repository.filters.UserFilter; +import org.radarbase.management.security.Constants; import org.radarbase.management.service.dto.UserDTO; import org.radarbase.management.service.mapper.UserMapper; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java index c0314ec75..755783950 100644 --- a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java @@ -1,21 +1,5 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +13,7 @@ import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.security.RadarAuthentication; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.UserService; import org.radarbase.management.service.dto.RoleDTO; @@ -44,6 +29,22 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the AccountResource REST controller. * @@ -73,6 +74,9 @@ class AccountResourceIntTest { @Autowired private RadarToken radarToken; + @Autowired + private AuthService authService; + @BeforeEach public void setUp() { MockitoAnnotations.initMocks(this); @@ -84,12 +88,14 @@ public void setUp() { ReflectionTestUtils.setField(accountResource, "userService", userService); ReflectionTestUtils.setField(accountResource, "userMapper", userMapper); ReflectionTestUtils.setField(accountResource, "mailService", mockMailService); + ReflectionTestUtils.setField(accountResource, "authService", authService); ReflectionTestUtils.setField(accountResource, "token", radarToken); AccountResource accountUserMockResource = new AccountResource(); ReflectionTestUtils.setField(accountUserMockResource, "userService", mockUserService); ReflectionTestUtils.setField(accountUserMockResource, "userMapper", userMapper); ReflectionTestUtils.setField(accountUserMockResource, "mailService", mockMailService); + ReflectionTestUtils.setField(accountUserMockResource, "authService", authService); ReflectionTestUtils.setField(accountUserMockResource, "token", radarToken); this.restUserMockMvc = MockMvcBuilders.standaloneSetup(accountUserMockResource).build(); @@ -114,7 +120,7 @@ void testAuthenticatedUser() throws Exception { Set roles = new HashSet<>(); org.radarbase.management.domain.Role role = new org.radarbase.management.domain.Role(); Authority authority = new Authority(); - authority.setName(RoleAuthority.SYS_ADMIN.authority()); + authority.setName(RoleAuthority.SYS_ADMIN.getAuthority()); role.setAuthority(authority); roles.add(role); @@ -142,7 +148,7 @@ void testAuthenticatedUser() throws Exception { .andExpect(jsonPath("$.email").value("john.doe@jhipster.com")) .andExpect(jsonPath("$.langKey").value("en")) .andExpect(jsonPath("$.authorities").value( - RoleAuthority.SYS_ADMIN.authority())); + RoleAuthority.SYS_ADMIN.getAuthority())); } @Test @@ -150,7 +156,7 @@ void testGetExistingAccount() throws Exception { Set roles = new HashSet<>(); org.radarbase.management.domain.Role role = new org.radarbase.management.domain.Role(); Authority authority = new Authority(); - authority.setName(RoleAuthority.SYS_ADMIN.authority()); + authority.setName(RoleAuthority.SYS_ADMIN.getAuthority()); role.setAuthority(authority); roles.add(role); @@ -173,7 +179,7 @@ void testGetExistingAccount() throws Exception { .andExpect(jsonPath("$.email").value("john.doe@jhipster.com")) .andExpect(jsonPath("$.langKey").value("en")) .andExpect(jsonPath("$.authorities").value( - RoleAuthority.SYS_ADMIN.authority())); + RoleAuthority.SYS_ADMIN.getAuthority())); } @Test @@ -190,7 +196,7 @@ void testGetUnknownAccount() throws Exception { void testSaveInvalidLogin() throws Exception { Set roles = new HashSet<>(); RoleDTO role = new RoleDTO(); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); roles.add(role); UserDTO invalidUser = new UserDTO(); diff --git a/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java index 9be987882..b7964196c 100644 --- a/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java @@ -4,14 +4,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.config.audit.AuditEventConverter; import org.radarbase.management.domain.PersistentAuditEvent; import org.radarbase.management.repository.PersistenceAuditEventRepository; import org.radarbase.management.security.JwtAuthenticationFilter; import org.radarbase.management.service.AuditEventService; -import org.radarbase.auth.authentication.OAuthHelper; +import org.radarbase.management.service.AuthService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -67,12 +67,11 @@ class AuditResourceIntTest { @Autowired private PageableHandlerMethodArgumentResolver pageableArgumentResolver; - @Autowired - private RadarToken radarToken; - private PersistentAuditEvent auditEvent; private MockMvc restAuditMockMvc; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { @@ -83,7 +82,7 @@ public void setUp() throws ServletException { ReflectionTestUtils.setField(auditEventService, "auditEventConverter", auditEventConverter); AuditResource auditResource = new AuditResource(); ReflectionTestUtils.setField(auditResource, "auditEventService", auditEventService); - ReflectionTestUtils.setField(auditResource, "token", radarToken); + ReflectionTestUtils.setField(auditResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.java index cfac9b106..db4c03fae 100644 --- a/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/GroupResourceIntTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; import org.radarbase.auth.authentication.OAuthHelper; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Group; import org.radarbase.management.domain.Project; @@ -16,8 +15,8 @@ import org.radarbase.management.repository.RoleRepository; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.GroupService; -import org.radarbase.management.service.ProjectService; import org.radarbase.management.service.SubjectService; import org.radarbase.management.service.dto.GroupDTO; import org.radarbase.management.service.dto.SubjectDTO; @@ -38,15 +37,13 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import javax.servlet.ServletException; - import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import static org.radarbase.management.service.dto.SubjectDTO.SubjectStatus.ACTIVATED; - import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasItem; +import static org.radarbase.management.service.dto.SubjectDTO.SubjectStatus.ACTIVATED; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @@ -89,9 +86,6 @@ class GroupResourceIntTest { @Autowired private GroupMapper groupMapper; - @Autowired - private ProjectService projectService; - @Autowired private PageableHandlerMethodArgumentResolver pageableArgumentResolver; @@ -101,22 +95,20 @@ class GroupResourceIntTest { @Autowired private GroupRepository groupRepository; - @Autowired - private RadarToken token; - private MockMvc restGroupMockMvc; private Group group; private Project project; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); var groupResource = new GroupResource(); ReflectionTestUtils.setField(groupResource, "groupService", groupService); - ReflectionTestUtils.setField(groupResource, "token", token); - ReflectionTestUtils.setField(groupResource, "projectService", projectService); + ReflectionTestUtils.setField(groupResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/LogsResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/LogsResourceIntTest.java index bf76cf284..a102450ae 100644 --- a/src/test/java/org/radarbase/management/web/rest/LogsResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/LogsResourceIntTest.java @@ -1,10 +1,5 @@ package org.radarbase.management.web.rest; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +12,11 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the LogsResource REST controller. * diff --git a/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java index 10e9a4bb4..077574a72 100644 --- a/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java @@ -1,26 +1,13 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; -import static org.radarbase.management.service.OAuthClientServiceTestUtil.createClient; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; import org.radarbase.auth.authentication.OAuthHelper; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalApp; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.OAuthClientService; import org.radarbase.management.service.SubjectService; import org.radarbase.management.service.UserService; @@ -42,6 +29,20 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.radarbase.management.service.OAuthClientServiceTestUtil.createClient; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the ProjectResource REST controller. * @@ -75,9 +76,6 @@ class OAuthClientsResourceIntTest { @Autowired private ExceptionTranslator exceptionTranslator; - @Autowired - private RadarToken radarToken; - private MockMvc restOauthClientMvc; private ClientDetailsDTO details; @@ -85,6 +83,8 @@ class OAuthClientsResourceIntTest { private List clientDetailsList; private int databaseSizeBeforeCreate; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws Exception { @@ -96,8 +96,8 @@ public void setUp() throws Exception { subjectService); ReflectionTestUtils.setField(oauthClientsResource, "userService", userService); - ReflectionTestUtils.setField(oauthClientsResource, "token", - radarToken); + ReflectionTestUtils.setField(oauthClientsResource, "authService", + authService); ReflectionTestUtils.setField(oauthClientsResource, "oAuthClientService", oAuthClientService); diff --git a/src/test/java/org/radarbase/management/web/rest/OrganizationResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/OrganizationResourceIntTest.java index fb75e6d31..a2f4d9ce4 100644 --- a/src/test/java/org/radarbase/management/web/rest/OrganizationResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/OrganizationResourceIntTest.java @@ -6,11 +6,11 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; import org.radarbase.auth.authentication.OAuthHelper; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Organization; import org.radarbase.management.repository.OrganizationRepository; import org.radarbase.management.repository.ProjectRepository; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.OrganizationService; import org.radarbase.management.service.mapper.OrganizationMapper; import org.radarbase.management.web.rest.errors.ExceptionTranslator; @@ -68,12 +68,11 @@ class OrganizationResourceIntTest { @Autowired private ExceptionTranslator exceptionTranslator; - @Autowired - private RadarToken token; - private MockMvc restOrganizationMockMvc; private Organization organization; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { @@ -81,7 +80,7 @@ public void setUp() throws ServletException { var orgResource = new OrganizationResource(); ReflectionTestUtils .setField(orgResource, "organizationService", organizationService); - ReflectionTestUtils.setField(orgResource, "token", token); + ReflectionTestUtils.setField(orgResource, "authService", authService); var filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/ProfileInfoResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/ProfileInfoResourceIntTest.java index f03b93cb5..cef4e56ff 100644 --- a/src/test/java/org/radarbase/management/web/rest/ProfileInfoResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/ProfileInfoResourceIntTest.java @@ -1,10 +1,5 @@ package org.radarbase.management.web.rest; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,6 +14,11 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the ProfileInfoResource REST controller. * diff --git a/src/test/java/org/radarbase/management/web/rest/ProjectResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/ProjectResourceIntTest.java index 4f0625b30..36767833b 100644 --- a/src/test/java/org/radarbase/management/web/rest/ProjectResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/ProjectResourceIntTest.java @@ -1,28 +1,11 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.hasItem; -import static org.radarbase.management.web.rest.TestUtil.sameInstant; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.util.List; -import javax.servlet.ServletException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Assertions; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Organization; import org.radarbase.management.domain.Project; @@ -30,11 +13,11 @@ import org.radarbase.management.repository.OrganizationRepository; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ProjectService; import org.radarbase.management.service.dto.ProjectDTO; import org.radarbase.management.service.mapper.ProjectMapper; import org.radarbase.management.web.rest.errors.ExceptionTranslator; -import org.radarbase.auth.authentication.OAuthHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -48,6 +31,24 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import javax.servlet.ServletException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.radarbase.management.web.rest.TestUtil.sameInstant; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the ProjectResource REST controller. * @@ -104,12 +105,11 @@ class ProjectResourceIntTest { @Autowired private ExceptionTranslator exceptionTranslator; - @Autowired - private RadarToken radarToken; - private MockMvc restProjectMockMvc; private Project project; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { @@ -117,7 +117,7 @@ public void setUp() throws ServletException { ProjectResource projectResource = new ProjectResource(); ReflectionTestUtils.setField(projectResource, "projectRepository", projectRepository); ReflectionTestUtils.setField(projectResource, "projectService", projectService); - ReflectionTestUtils.setField(projectResource, "token", radarToken); + ReflectionTestUtils.setField(projectResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/SourceDataResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/SourceDataResourceIntTest.java index 3d056ecc0..9b02328c5 100644 --- a/src/test/java/org/radarbase/management/web/rest/SourceDataResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/SourceDataResourceIntTest.java @@ -6,11 +6,11 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; import org.radarbase.auth.authentication.OAuthHelper; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.SourceData; import org.radarbase.management.repository.SourceDataRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.SourceDataService; import org.radarbase.management.service.dto.SourceDataDTO; import org.radarbase.management.service.mapper.SourceDataMapper; @@ -97,19 +97,18 @@ class SourceDataResourceIntTest { @Autowired private EntityManager em; - @Autowired - private RadarToken radarToken; - private MockMvc restSourceDataMockMvc; private SourceData sourceData; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); SourceDataResource sourceDataResource = new SourceDataResource(); ReflectionTestUtils.setField(sourceDataResource, "sourceDataService", sourceDataService); - ReflectionTestUtils.setField(sourceDataResource, "token", radarToken); + ReflectionTestUtils.setField(sourceDataResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/SourceResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/SourceResourceIntTest.java index ee1fd45e2..6bdb6c70b 100644 --- a/src/test/java/org/radarbase/management/web/rest/SourceResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/SourceResourceIntTest.java @@ -1,33 +1,18 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.everyItem; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.notNullValue; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.List; -import java.util.UUID; -import javax.servlet.ServletException; - +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Assertions; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.repository.SourceRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.SourceService; import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.dto.SourceDTO; @@ -35,7 +20,6 @@ import org.radarbase.management.service.mapper.SourceMapper; import org.radarbase.management.service.mapper.SourceTypeMapper; import org.radarbase.management.web.rest.errors.ExceptionTranslator; -import org.radarbase.auth.authentication.OAuthHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -50,6 +34,22 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import javax.servlet.ServletException; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the DeviceResource REST controller. * @@ -92,9 +92,6 @@ class SourceResourceIntTest { @Autowired private ExceptionTranslator exceptionTranslator; - @Autowired - private RadarToken radarToken; - @Autowired private ProjectRepository projectRepository; @@ -103,12 +100,14 @@ class SourceResourceIntTest { private Source source; private Project project; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); SourceResource sourceResource = new SourceResource(); - ReflectionTestUtils.setField(sourceResource, "token", radarToken); + ReflectionTestUtils.setField(sourceResource, "authService", authService); ReflectionTestUtils.setField(sourceResource, "sourceService", sourceService); ReflectionTestUtils.setField(sourceResource, "sourceRepository", sourceRepository); diff --git a/src/test/java/org/radarbase/management/web/rest/SourceTypeResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/SourceTypeResourceIntTest.java index d2bd7d2b9..f11b33b53 100644 --- a/src/test/java/org/radarbase/management/web/rest/SourceTypeResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/SourceTypeResourceIntTest.java @@ -1,39 +1,24 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.hasItem; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import javax.persistence.EntityManager; -import javax.servlet.ServletException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.Assertions; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.SourceData; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.SourceDataRepository; import org.radarbase.management.repository.SourceTypeRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.dto.SourceDataDTO; import org.radarbase.management.service.dto.SourceTypeDTO; import org.radarbase.management.service.mapper.SourceDataMapper; import org.radarbase.management.service.mapper.SourceTypeMapper; import org.radarbase.management.web.rest.errors.ExceptionTranslator; -import org.radarbase.auth.authentication.OAuthHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -48,6 +33,22 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import javax.persistence.EntityManager; +import javax.servlet.ServletException; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the SourceTypeResource REST controller. * @@ -97,12 +98,11 @@ class SourceTypeResourceIntTest { @Autowired private EntityManager em; - @Autowired - private RadarToken radarToken; - private MockMvc restSourceTypeMockMvc; private SourceType sourceType; + @Autowired + private AuthService authService; @BeforeEach public void setUp() throws ServletException { @@ -111,7 +111,7 @@ public void setUp() throws ServletException { ReflectionTestUtils.setField(sourceTypeResource, "sourceTypeService" , sourceTypeService); ReflectionTestUtils.setField(sourceTypeResource, "sourceTypeRepository" , sourceTypeRepository); - ReflectionTestUtils.setField(sourceTypeResource, "token", radarToken); + ReflectionTestUtils.setField(sourceTypeResource, "authService", authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); diff --git a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java index 62a2196f4..df305e1df 100644 --- a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java @@ -1,48 +1,16 @@ package org.radarbase.management.web.rest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_ENTERNAL_ID; -import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_EXTERNAL_LINK; -import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_REMOVED; -import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_STATUS; -import static org.radarbase.management.service.SubjectServiceTest.MODEL; -import static org.radarbase.management.service.SubjectServiceTest.PRODUCER; -import static org.radarbase.management.service.SubjectServiceTest.UPDATED_ENTERNAL_ID; -import static org.radarbase.management.service.SubjectServiceTest.UPDATED_EXTERNAL_LINK; -import static org.radarbase.management.service.SubjectServiceTest.UPDATED_REMOVED; -import static org.radarbase.management.service.SubjectServiceTest.createEntityDTO; -import static org.radarbase.management.web.rest.TestUtil.commitTransactionAndStartNew; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.util.Collections; -import java.util.UUID; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import javax.servlet.ServletException; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; -import org.radarbase.auth.token.RadarToken; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Subject; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.SourceService; import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.SubjectService; @@ -53,7 +21,6 @@ import org.radarbase.management.service.dto.SubjectDTO; import org.radarbase.management.service.mapper.SubjectMapper; import org.radarbase.management.web.rest.errors.ExceptionTranslator; -import org.radarbase.auth.authentication.OAuthHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -68,6 +35,39 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; +import javax.servlet.ServletException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_ENTERNAL_ID; +import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_EXTERNAL_LINK; +import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_REMOVED; +import static org.radarbase.management.service.SubjectServiceTest.DEFAULT_STATUS; +import static org.radarbase.management.service.SubjectServiceTest.MODEL; +import static org.radarbase.management.service.SubjectServiceTest.PRODUCER; +import static org.radarbase.management.service.SubjectServiceTest.UPDATED_ENTERNAL_ID; +import static org.radarbase.management.service.SubjectServiceTest.UPDATED_EXTERNAL_LINK; +import static org.radarbase.management.service.SubjectServiceTest.UPDATED_REMOVED; +import static org.radarbase.management.service.SubjectServiceTest.createEntityDTO; +import static org.radarbase.management.web.rest.TestUtil.commitTransactionAndStartNew; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + /** * Test class for the SubjectResource REST controller. * @@ -105,11 +105,11 @@ class SubjectResourceIntTest { @Autowired private ProjectRepository projectRepository; - @Autowired - private RadarToken radarToken; - private MockMvc restSubjectMockMvc; + @Autowired + private AuthService authService; + @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); @@ -119,7 +119,7 @@ public void setUp() throws ServletException { ReflectionTestUtils.setField(subjectResource, "subjectMapper", subjectMapper); ReflectionTestUtils.setField(subjectResource, "projectRepository", projectRepository); ReflectionTestUtils.setField(subjectResource, "sourceTypeService", sourceTypeService); - ReflectionTestUtils.setField(subjectResource, "token", radarToken); + ReflectionTestUtils.setField(subjectResource, "authService", authService); ReflectionTestUtils.setField(subjectResource, "sourceService", sourceService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); diff --git a/src/test/java/org/radarbase/management/web/rest/UserResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/UserResourceIntTest.java index be02a99e1..32398df8e 100644 --- a/src/test/java/org/radarbase/management/web/rest/UserResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/UserResourceIntTest.java @@ -5,8 +5,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.MockitoAnnotations; +import org.radarbase.auth.authentication.OAuthHelper; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Authority; @@ -17,13 +17,13 @@ import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.security.JwtAuthenticationFilter; +import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.PasswordService; import org.radarbase.management.service.UserService; import org.radarbase.management.service.dto.RoleDTO; import org.radarbase.management.web.rest.errors.ExceptionTranslator; import org.radarbase.management.web.rest.vm.ManagedUserVM; -import org.radarbase.auth.authentication.OAuthHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -38,14 +38,15 @@ import org.springframework.transaction.annotation.Transactional; import javax.servlet.ServletException; -import java.util.HashSet; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasItem; +import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN_AUTHORITY; import static org.radarbase.management.service.UserServiceIntTest.DEFAULT_EMAIL; import static org.radarbase.management.service.UserServiceIntTest.DEFAULT_FIRSTNAME; @@ -59,7 +60,6 @@ import static org.radarbase.management.service.UserServiceIntTest.UPDATED_LASTNAME; import static org.radarbase.management.service.UserServiceIntTest.UPDATED_LOGIN; import static org.radarbase.management.service.UserServiceIntTest.UPDATED_PASSWORD; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; import static org.radarbase.management.service.UserServiceIntTest.createEntity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -107,7 +107,7 @@ class UserResourceIntTest { private SubjectRepository subjectRepository; @Autowired - private RadarToken radarToken; + private AuthService authService; @Autowired private PasswordService passwordService; @@ -129,7 +129,7 @@ public void setUp() throws ServletException { ReflectionTestUtils.setField(userResource, "mailService", mailService); ReflectionTestUtils.setField(userResource, "userRepository", userRepository); ReflectionTestUtils.setField(userResource, "subjectRepository", subjectRepository); - ReflectionTestUtils.setField(userResource, "token", radarToken); + ReflectionTestUtils.setField(userResource, "authService", authService); ReflectionTestUtils.setField(userResource, "managementPortalProperties", managementPortalProperties); @@ -160,7 +160,7 @@ public void initTest() { userRepository.findOneByLogin(UPDATED_LOGIN) .ifPresent(userRepository::delete); var roles = roleRepository - .findRolesByAuthorityName(RoleAuthority.PARTICIPANT.authority()) + .findRolesByAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()) .stream().filter(r -> r.getProject() == null) .collect(Collectors.toList()); roleRepository.deleteAll(roles); @@ -202,7 +202,7 @@ void createUserWithExistingId() throws Exception { Set roles = new HashSet<>(); RoleDTO role = new RoleDTO(); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); roles.add(role); ManagedUserVM managedUserVm = createDefaultUser(roles); @@ -228,7 +228,7 @@ void createUserWithExistingLogin() throws Exception { Set roles = new HashSet<>(); RoleDTO role = new RoleDTO(); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); roles.add(role); ManagedUserVM managedUserVm = createDefaultUser(roles); managedUserVm.setEmail("anothermail@localhost"); @@ -253,7 +253,7 @@ void createUserWithExistingEmail() throws Exception { Set roles = new HashSet<>(); RoleDTO role = new RoleDTO(); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); roles.add(role); ManagedUserVM managedUserVm = createDefaultUser(roles); managedUserVm.setLogin("anotherlogin"); @@ -350,7 +350,7 @@ void updateUser() throws Exception { RoleDTO role = new RoleDTO(); role.setProjectId(project.getId()); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); managedUserVm.setRoles(Set.of(role)); restUserMockMvc.perform(put("/api/users") @@ -394,7 +394,7 @@ void updateUserLogin() throws Exception { RoleDTO role = new RoleDTO(); role.setProjectId(project.getId()); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); managedUserVm.setRoles(Set.of(role)); restUserMockMvc.perform(put("/api/users") @@ -406,7 +406,7 @@ void updateUserLogin() throws Exception { List userList = userRepository.findAll(); assertThat(userList).hasSize(databaseSizeBeforeUpdate); User testUser = userList.get(userList.size() - 1); - assertThat(testUser.getLogin()).isEqualTo(UPDATED_LOGIN); + assertThat(testUser.getLogin()).isEqualTo(DEFAULT_LOGIN); assertThat(testUser.getFirstName()).isEqualTo(UPDATED_FIRSTNAME); assertThat(testUser.getLastName()).isEqualTo(UPDATED_LASTNAME); assertThat(testUser.getEmail()).isEqualTo(UPDATED_EMAIL); @@ -447,7 +447,7 @@ void updateUserExistingEmail() throws Exception { RoleDTO role = new RoleDTO(); role.setProjectId(project.getId()); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); managedUserVm.setRoles(Set.of(role)); restUserMockMvc.perform(put("/api/users") @@ -490,7 +490,7 @@ void updateUserExistingLogin() throws Exception { RoleDTO role = new RoleDTO(); role.setProjectId(project.getId()); - role.setAuthorityName(RoleAuthority.PARTICIPANT.authority()); + role.setAuthorityName(RoleAuthority.PARTICIPANT.getAuthority()); managedUserVm.setRoles(Set.of(role)); restUserMockMvc.perform(put("/api/users") diff --git a/src/test/java/org/radarbase/management/webapp/CheckTranslationsUnitTest.java b/src/test/java/org/radarbase/management/webapp/CheckTranslationsUnitTest.java index 39f198525..2132b8446 100644 --- a/src/test/java/org/radarbase/management/webapp/CheckTranslationsUnitTest.java +++ b/src/test/java/org/radarbase/management/webapp/CheckTranslationsUnitTest.java @@ -6,6 +6,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + import java.io.File; import java.io.IOException; import java.util.Arrays; @@ -16,10 +20,6 @@ import java.util.Map; import java.util.stream.Collectors; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.assertEquals; /** diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 75dff301b..6d4d71579 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -3,6 +3,7 @@ + From 0347668186444e1bbd4179d80b590397f47e1c3e Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 22 Feb 2023 15:20:09 +0100 Subject: [PATCH 006/120] Tested logic with radar-jersey --- build.gradle | 2 +- radar-auth/build.gradle | 2 +- .../auth/authentication/TokenValidator.kt | 132 +++---- .../AuthorityReference.kt | 7 +- .../authorization/AuthorityReferenceSet.kt | 15 +- .../auth/authorization/AuthorizationOracle.kt | 325 +----------------- .../authorization/MPAuthorizationOracle.kt | 307 +++++++++++++++++ .../auth/authorization/RoleAuthority.kt | 20 +- .../auth/jwks/ECPEMCertificateParser.kt | 6 +- .../org/radarbase/auth/jwks/Extensions.kt | 27 ++ .../org/radarbase/auth/jwks/JsonWebKey.kt | 21 +- .../radarbase/auth/jwks/JwkAlgorithmParser.kt | 55 ++- .../auth/jwks/JwksTokenVerifierLoader.kt | 9 +- .../auth/jwks/RSAPEMCertificateParser.kt | 6 +- .../org/radarbase/auth/jwt/JwtRadarToken.kt | 98 ------ .../radarbase/auth/jwt/JwtTokenVerifier.kt | 80 ++++- .../auth/token/AbstractRadarToken.kt | 55 --- .../radarbase/auth/token/DataRadarToken.kt | 89 +++++ .../org/radarbase/auth/token/RadarToken.kt | 50 ++- .../org/radarbase/auth/util/CachedValue.kt | 116 +++++-- .../org/radarbase/auth/util/Extensions.kt | 36 +- .../authentication/TokenValidatorTest.java | 21 +- .../authorization/RadarAuthorizationTest.java | 221 ------------ .../authorization/RadarAuthorizationTest.kt | 205 +++++++++++ .../auth/token/AbstractRadarTokenTest.java | 256 -------------- .../auth/token/AbstractRadarTokenTest.kt | 227 ++++++++++++ .../radarbase/auth/util/ExtensionsKtTest.kt | 80 +++++ .../radarbase/auth/util/TokenTestUtils.java | 272 --------------- .../org/radarbase/auth/util/TokenTestUtils.kt | 284 +++++++++++++++ .../security/ClaimsTokenEnhancer.java | 17 +- .../security/JwtAuthenticationFilter.java | 31 +- .../security}/NotAuthorizedException.kt | 2 +- .../security/RadarAuthentication.java | 2 +- .../security/SessionRadarToken.java | 163 --------- .../management/service/AuthService.kt | 70 ++-- .../management/service/MetaTokenService.java | 2 +- .../service/OrganizationService.java | 8 +- .../management/service/ProjectService.java | 2 +- .../management/service/SourceService.java | 2 +- .../management/service/SubjectService.java | 2 +- .../management/service/UserService.java | 2 +- .../management/web/rest/AccountResource.java | 6 +- .../management/web/rest/AuditResource.java | 2 +- .../web/rest/AuthorityResource.java | 2 +- .../management/web/rest/GroupResource.java | 2 +- .../web/rest/MetaTokenResource.java | 4 +- .../web/rest/OAuthClientsResource.java | 6 +- .../web/rest/OrganizationResource.java | 2 +- .../management/web/rest/ProjectResource.java | 2 +- .../management/web/rest/RoleResource.java | 2 +- .../web/rest/SourceDataResource.java | 2 +- .../management/web/rest/SourceResource.java | 2 +- .../web/rest/SourceTypeResource.java | 2 +- .../management/web/rest/SubjectResource.java | 2 +- .../management/web/rest/UserResource.java | 2 +- .../web/rest/errors/ExceptionTranslator.java | 6 +- .../management/config/MockConfiguration.java | 40 --- .../management/config/MockConfiguration.kt | 34 ++ .../service/UserServiceIntTest.java | 2 +- .../web/rest/SubjectResourceIntTest.java | 1 - 60 files changed, 1726 insertions(+), 1722 deletions(-) rename radar-auth/src/main/java/org/radarbase/auth/{token => authorization}/AuthorityReference.kt (88%) create mode 100644 radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwks/Extensions.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt create mode 100644 radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt delete mode 100644 radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java create mode 100644 radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt delete mode 100644 radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java create mode 100644 radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt create mode 100644 radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt delete mode 100644 radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java create mode 100644 radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.kt rename {radar-auth/src/main/java/org/radarbase/auth/exception => src/main/java/org/radarbase/management/security}/NotAuthorizedException.kt (89%) delete mode 100644 src/main/java/org/radarbase/management/security/SessionRadarToken.java delete mode 100644 src/test/java/org/radarbase/management/config/MockConfiguration.java create mode 100644 src/test/java/org/radarbase/management/config/MockConfiguration.kt diff --git a/build.gradle b/build.gradle index 84a378ab8..3dc55c03d 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ apply plugin: 'io.spring.dependency-management' allprojects { group 'org.radarbase' - version '0.9.0-SNAPSHOT' // project version + version '0.10.1-SNAPSHOT' // project version // The comment on the previous line is only there to identify the project version line easily // with a sed command, to auto-update the version number with the prepare-release-branch.sh diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index e0c040032..5cc77f19f 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -27,7 +27,7 @@ dependencies { testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' - testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' + testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '2.2' testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: logback_version testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt index 16c827600..58800dc39 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt @@ -1,11 +1,7 @@ package org.radarbase.auth.authentication import com.auth0.jwt.exceptions.AlgorithmMismatchException -import com.auth0.jwt.interfaces.DecodedJWT import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.consume import org.radarbase.auth.exception.TokenValidationException import org.radarbase.auth.token.RadarToken import org.radarbase.auth.util.CachedValue @@ -35,8 +31,9 @@ constructor( ) { private val algorithmLoaders: List = verifierLoaders.map { loader -> CachedValue( - minAge = fetchTimeout, - maxAge = maxAge, + retryDuration = fetchTimeout, + refreshDuration = maxAge, + maxSimultaneousCompute = 2, ) { loader.fetch() } @@ -45,49 +42,53 @@ constructor( /** * Validates an access token and returns the token as a [RadarToken] object. * - * If we have not yet fetched the JWT public key, this method will fetch it. If a signature can - * not be verified, this method will fetch the JWT public key again, as it might have been - * changed, and re-check the token. However, this fetching of the public key will only be - * performed at most once every `fetchTimeout` seconds, to prevent (malicious) - * clients from making us call the token endpoint too frequently. + * This will load all the verifiers. If a token cannot be verified, this method will fetch + * the verifiers again, as the source may have changed. It will then and re-check the token. + * However, the public key will not be fetched more than once every `fetchTimeout`, + * to prevent (malicious) clients from loading external token verifiers too frequently. + * + * This implementation calls [runBlocking]. If calling from Kotlin, prefer to use [validate] + * with coroutines instead. * * @param token The access token * @return The decoded access token * @throws TokenValidationException If the token can not be validated. */ @Throws(TokenValidationException::class) - fun authenticateBlocking(token: String): RadarToken = runBlocking { - authenticate(token) + fun validateBlocking(token: String): RadarToken = runBlocking { + validate(token) } /** - * Validates an access token and returns the decoded JWT as a [DecodedJWT] object. + * Validates an access token and returns the token as a [RadarToken] object. * - * If we have not yet fetched the JWT public key, this method will fetch it. If a signature can - * not be verified, this method will fetch the JWT public key again, as it might have been - * changed, and re-check the token. However, this fetching of the public key will only be - * performed at most once every `fetchTimeout` seconds, to prevent (malicious) - * clients from making us call the token endpoint too frequently. + * This will load all the verifiers. If a token cannot be verified, this method will fetch + * the verifiers again, as the source may have changed. It will then and re-check the token. + * However, the public key will not be fetched more than once every `fetchTimeout`, + * to prevent (malicious) clients from loading external token verifiers too frequently. * * @param token The access token * @return The decoded access token * @throws TokenValidationException If the token can not be validated. */ @Throws(TokenValidationException::class) - suspend fun authenticate(token: String): RadarToken { - val result: Result = consumeFirst { channel -> + suspend fun validate(token: String): RadarToken { + val result: Result = consumeFirst { emit -> val errors = algorithmLoaders .forkJoin { cache -> val result = cache.verify(token) // short-circuit to return the first successful result - if (result.isSuccess) channel.send(result) + if (result.isSuccess) emit(result) result } - .mapNotNull { it.exceptionOrNull() } - .flatMap { it.suppressedExceptions } + .flatMap { + it.exceptionOrNull() + ?.suppressedExceptions + ?: emptyList() + } val suppressedMessage = errors.joinToString { it.message ?: it.javaClass.simpleName } - channel.send( + emit( TokenValidationException("No registered validator in could authenticate this token: $suppressedMessage") .toFailure(errors) ) @@ -101,35 +102,60 @@ constructor( algorithmLoaders.forEach { it.clear() } } - /** - * Verify the token using the TokenVerifier lists from cache. - * If verification fails and the TokenVerifier list was retrieved from cache - * try to reload the TokenVerifier list and verify again. - * If none of the verifications succeed, return a result of TokenValidationException - * with suppressed exceptions all the exceptions returned from a TokenVerifier. - */ - private suspend fun TokenVerifierCache.verify(token: String): Result { - val verifiers = getOrEmpty(false) - - var results = verifiers.value.map { it.runCatching { verify(token) } } - results.find { it.isSuccess }?.let { return it } + companion object { + private val logger = LoggerFactory.getLogger(TokenValidator::class.java) - // already fetched a new value, no need to fetch it again - if (verifiers is CachedValue.CacheMiss) return results.toValidationExceptionResult() + /** + * Verify the token using the TokenVerifier lists from cache. + * If verification fails and the TokenVerifier list was retrieved from cache + * try to reload the TokenVerifier list and verify again. + * If none of the verifications succeed, return a result of TokenValidationException + * with suppressed exceptions all the exceptions returned from a TokenVerifier. + */ + private suspend fun TokenVerifierCache.verify(token: String): Result { + val verifiers = getOrEmpty { false } + + val firstResult = verifiers.value.anyVerify(token) + if ( + firstResult.isSuccess || + // already fetched new verifiers, no need to fetch it again + verifiers is CachedValue.CacheMiss + ) { + return firstResult + } - val refreshedVerifiers = getOrEmpty(true) - if (refreshedVerifiers == verifiers) return results.toValidationExceptionResult() + val refreshedVerifiers = getOrEmpty { true } + return if (refreshedVerifiers != verifiers) { + refreshedVerifiers.value.anyVerify(token) + } else { + // The verifiers didn't change, so the result won't change + firstResult + } + } - results = refreshedVerifiers.value.map { it.runCatching { verify(token) } } - results.find { it.isSuccess }?.let { return it } - return results.toValidationExceptionResult() - } + private fun List.anyVerify(token: String): Result { + var exceptions: MutableList? = null + + forEach { verifier -> + try { + val radarToken = verifier.verify(token) + return Result.success(radarToken) + } catch (ex: Throwable) { + if (ex !is AlgorithmMismatchException) { + if (exceptions == null) { + exceptions = mutableListOf() + } + exceptions!!.add(ex) + } + } + } - companion object { - private val logger = LoggerFactory.getLogger(TokenValidator::class.java) + return TokenValidationException("Failed to validate token") + .toFailure(exceptions ?: emptyList()) + } private suspend fun TokenVerifierCache.getOrEmpty( - refresh: Boolean + refresh: (List) -> Boolean ): CachedValue.CacheResult> = try { get(refresh) @@ -138,16 +164,6 @@ constructor( CachedValue.CacheMiss(emptyList()) } - private fun List>.toValidationExceptionResult(): Result { - val exceptions = mapNotNull { result -> - result.exceptionOrNull() - ?.takeIf { it !is AlgorithmMismatchException } - } - - return TokenValidationException("Failed to validate token") - .toFailure(exceptions) - } - private fun Throwable.toFailure(causes: Iterable = emptyList()): Result { causes.forEach { addSuppressed(it) } return Result.failure(this) diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReference.kt similarity index 88% rename from radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt rename to radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReference.kt index 4d9ad9b8b..0810a6676 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/token/AuthorityReference.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReference.kt @@ -6,12 +6,15 @@ * * See the file LICENSE in the root of this repository. */ -package org.radarbase.auth.token +package org.radarbase.auth.authorization -import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.authorization.RoleAuthority.Companion.valueOfAuthority import java.io.Serializable +/** + * An authority referenced to a specific entity. Only roles with global scope do not need a + * referent. + */ data class AuthorityReference( val role: RoleAuthority, val authority: String, diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt index 9fb55be5f..45daa0caf 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt @@ -1,5 +1,7 @@ package org.radarbase.auth.authorization +import org.radarbase.auth.util.plus + data class AuthorityReferenceSet( /** Identity has global authority. */ val global: Boolean = false, @@ -7,6 +9,8 @@ data class AuthorityReferenceSet( val organizations: Set = emptySet(), /** Identity has explicit authority over these projects. */ val projects: Set = emptySet(), + /** Identity has explicit personal authority over these projects. */ + val personalProjects: Set = emptySet(), ) { /** Identity does not have any authority. */ fun isEmpty(): Boolean = !global && organizations.isEmpty() && projects.isEmpty() @@ -14,9 +18,12 @@ data class AuthorityReferenceSet( /** Identity has authority over the given [organization]. */ fun hasOrganization(organization: String): Boolean = organization in organizations - /** Identity has authority over the given [project]. */ - fun hasProject(project: String) = project in projects + val allProjects: Set + get() = projects + personalProjects + + /** Identity has authority over any project, personal or not. */ + fun hasAnyProject(project: String) = project in projects || project in personalProjects - /** Whether identity has explicit authority over any projects. */ - fun hasProjects() = projects.isNotEmpty() + /** Identity has authority over any project. */ + fun hasAnyProjects() = projects.isNotEmpty() || personalProjects.isNotEmpty() } diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt index dcff0ce21..6826b264e 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt @@ -1,49 +1,34 @@ package org.radarbase.auth.authorization -import org.radarbase.auth.exception.NotAuthorizedException -import org.radarbase.auth.token.AuthorityReference import org.radarbase.auth.token.RadarToken import java.util.* import java.util.function.Consumer -class AuthorizationOracle( - private val relationService: EntityRelationService -) { +interface AuthorizationOracle { /** - * Check whether [identity] has permission [permission], regarding given [entity]. The - * entity can be constructed using a builder pattern. The permission is checked both for its + * Whether [identity] has permission [permission], regarding given [entity]. An additional + * [entityScope] can be provided to check whether the permission is also valid regarding that + * scope. The permission is checked both for its * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @throws NotAuthorizedException if identity does not have permission + * @return true if identity has permission, false otheriwse */ - @Throws(NotAuthorizedException::class) - fun checkPermission( + fun hasPermission( identity: RadarToken, permission: Permission, - entity: Consumer - ) = checkPermission(identity, permission, EntityDetails().apply { entity.accept(this) }) + entityBuilder: Consumer, + ): Boolean = hasPermission(identity, permission, EntityDetails().apply(entityBuilder::accept)) /** - * Check whether [identity] has permission [permission], regarding given [entity]. An additional + * Whether [identity] has permission [permission], regarding given [entity]. An additional * [entityScope] can be provided to check whether the permission is also valid regarding that * scope. The permission is checked both for its * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @throws NotAuthorizedException if identity does not have permission + * @return true if identity has permission, false otheriwse */ - @JvmOverloads - @Throws(NotAuthorizedException::class) - fun checkPermission( + fun hasGlobalPermission( identity: RadarToken, permission: Permission, - entity: EntityDetails = EntityDetails.global, - entityScope: Permission.Entity = permission.entity, - ) { - if (!hasPermission(identity, permission, entity, entityScope)) { - throw NotAuthorizedException( - "User ${identity.username} with client ${identity.clientId} does not have permission $permission to scope " + - "$entityScope of $entity" - ) - } - } + ): Boolean = hasPermission(identity, permission) /** * Whether [identity] has permission [permission], regarding given [entity]. An additional @@ -52,63 +37,19 @@ class AuthorizationOracle( * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. * @return true if identity has permission, false otheriwse */ - @JvmOverloads fun hasPermission( identity: RadarToken, permission: Permission, entity: EntityDetails = EntityDetails.global, entityScope: Permission.Entity = permission.entity, - ): Boolean { - if (permission.scope() !in identity.scopes) return false - - if (identity.isClientCredentials) return true - - return identity.roles.any { - it.hasPermission(identity, permission, entity, entityScope) - } - } - - /** - * Whether [identity] has permission [permission], regarding given [entity]. The - * entity can be constructed using a builder pattern. The permission is checked both for its - * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @return true if identity has permission, false otheriwse - */ - fun hasPermission( - identity: RadarToken, - permission: Permission, - entity: Consumer - ) = hasPermission(identity, permission, EntityDetails().apply { entity.accept(this) }) - - /** - * Check whether given [identity] would have the [permission] scope in any of its roles. This doesn't - * check whether [identity] has access to a specific entity or global access. - * @throws NotAuthorizedException if identity does not have scope - */ - @Throws(NotAuthorizedException::class) - fun checkScope( - identity: RadarToken, - permission: Permission, - ) { - if (!hasScope(identity, permission)) { - throw NotAuthorizedException( - "User ${identity.username} with client ${identity.clientId} does not have permission $permission" - ) - } - } + ): Boolean /** * Whether given [identity] would have the [permission] scope in any of its roles. This doesn't * check whether [identity] has access to a specific entity or global access. * @return true if identity has scope, false otherwise */ - fun hasScope(identity: RadarToken, permission: Permission): Boolean { - if (permission.scope() !in identity.scopes) return false - - if (identity.isClientCredentials) return true - - return identity.roles.any { it.role.mayBeGranted(permission) } - } + fun hasScope(identity: RadarToken, permission: Permission): Boolean /** * Return a list of referents, per scope, that given [identity] has given [permission] on. @@ -120,240 +61,8 @@ class AuthorizationOracle( fun referentsByScope( identity: RadarToken, permission: Permission - ): AuthorityReferenceSet { - var global = false - val organizations = mutableSetOf() - val projects = mutableSetOf() + ): AuthorityReferenceSet - identity.roles.forEach { - if (it.role.mayBeGranted(permission)) { - when (it.role.scope) { - RoleAuthority.Scope.GLOBAL -> global = true - RoleAuthority.Scope.ORGANIZATION -> organizations.add(it.referent!!) - RoleAuthority.Scope.PROJECT -> projects.add(it.referent!!) - } - } - } - - return AuthorityReferenceSet( - global = global, - organizations = organizations, - projects = projects - ) - } - - /** - * Whether the current role from [identity] has [permission] over given [entity] in - * [entityScope] in any way. - */ - private fun AuthorityReference.hasPermission( - identity: RadarToken, - permission: Permission, - entity: EntityDetails, - entityScope: Permission.Entity, - ): Boolean { - if (!role.mayBeGranted(permission)) return false - if (role.scope == RoleAuthority.Scope.GLOBAL) return true - // if no entity scope is available and the role scope is not global, no matching authority - // can be found. - val minEntityScope = entity.minimumEntityOrNull() ?: return false - return hasAuthority(identity, permission, entity, entityScope) && - (entityScope == minEntityScope || - hasAuthority(identity, permission, entity, minEntityScope)) - } - - /** - * Whether the current role from [identity] has a specific authority with [permission] - * over given [entity] in [entityScope] - */ - private fun AuthorityReference.hasAuthority( - identity: RadarToken, - permission: Permission, - entity: EntityDetails, - entityScope: Permission.Entity, - ): Boolean = when (entityScope) { - Permission.Entity.MEASUREMENT -> hasAuthority(identity, permission, entity, Permission.Entity.SOURCE) - Permission.Entity.SOURCE -> (!role.isPersonal || - // no specific source is mentioned -> just check the subject - entity.source == null || - entity.source in identity.sources) && - hasAuthority(identity, permission, entity, Permission.Entity.SUBJECT) - Permission.Entity.SUBJECT -> (!role.isPersonal || - entity.subject == identity.subject) && - hasAuthority(identity, permission, entity, Permission.Entity.PROJECT) - Permission.Entity.PROJECT -> when (role.scope) { - RoleAuthority.Scope.PROJECT -> referent == entity.project - RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent - else -> false - } - Permission.Entity.ORGANIZATION -> when (role.scope) { - RoleAuthority.Scope.PROJECT -> referent == entity.project || entity.organizationContainsProject(referent!!) - RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent - else -> false - } - Permission.Entity.USER -> entity.user == identity.username || !role.isPersonal - else -> true - } - - private fun EntityDetails.findOrganization(): String? { - organization?.let { return it } - val p = project ?: return null - return relationService.findOrganizationOfProject(p) - .also { this.organization = it } - } - - private fun EntityDetails.organizationContainsProject(targetProject: String): Boolean { - val org = findOrganization() ?: return false - return relationService.organizationContainsProject(org, targetProject) - } - - /** - * Created by dverbeec on 22/09/2017. - */ - companion object Permissions { - /** - * Get the permission matrix. - * - * - * The permission matrix maps each [Permission] to a set of authorities that have that - * permission. - * @return An unmodifiable view of the permission matrix. - */ - @JvmStatic - val permissionMatrix: Map> = createPermissions() - - /** - * Look up the allowed authorities for a given permission. Authorities are String constants that - * appear in [RoleAuthority]. - * @param permission The permission to look up. - * @return An unmodifiable view of the set of allowed authorities. - */ - @JvmStatic - fun allowedRoles(permission: Permission): Set { - return permissionMatrix[permission] ?: emptySet() - } - - /** - * Static permission matrix based on the currently agreed upon security rules. - */ - private fun createPermissions(): Map> { - val rolePermissions: MutableMap> = - EnumMap(RoleAuthority::class.java) - - // System admin can do everything. - rolePermissions[RoleAuthority.SYS_ADMIN] = Permission.values().asSequence() - - // Organization admin can do most things, but not view subjects or measurements - rolePermissions[RoleAuthority.ORGANIZATION_ADMIN] = Permission.values().asSequence() - .exclude( - Permission.ORGANIZATION_CREATE, - Permission.SOURCEDATA_CREATE, - Permission.SOURCETYPE_CREATE - ) - .excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.MEASUREMENT - ) - - // for all authorities except for SYS_ADMIN, the authority is scoped to a project, which - // is checked elsewhere - // Project Admin - has all currently defined permissions except creating new projects - // Note: from radar-auth:0.5.7 we allow PROJECT_ADMIN to create measurements. - // This can be done by uploading data through the web application. - rolePermissions[RoleAuthority.PROJECT_ADMIN] = Permission.values().asSequence() - .exclude(Permission.PROJECT_CREATE) - .excludeEntities(Permission.Entity.AUDIT, Permission.Entity.AUTHORITY) - .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) - - /* Project Owner */ - // CRUD operations on subjects to allow enrollment - rolePermissions[RoleAuthority.PROJECT_OWNER] = Permission.values().asSequence() - .exclude(Permission.PROJECT_CREATE) - .excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER - ) - .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) - - /* Project affiliate */ - // Create, read and update participant (no delete) - rolePermissions[RoleAuthority.PROJECT_AFFILIATE] = Permission.values().asSequence() - .exclude(Permission.SUBJECT_DELETE) - .excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER - ) - .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) - .limitEntityOperations(Permission.Entity.PROJECT, Permission.Operation.READ) - - /* Project analyst */ - // Can read everything except users, authorities and audits - rolePermissions[RoleAuthority.PROJECT_ANALYST] = Permission.values().asSequence() - .excludeEntities( - Permission.Entity.AUDIT, - Permission.Entity.AUTHORITY, - Permission.Entity.USER - ) - // Can add metadata to sources, only read other things. - .filter { p -> - p.operation == Permission.Operation.READ || - p == Permission.SUBJECT_UPDATE - } - - /* Participant */ - // Can update and read own data and can read and write own measurements - rolePermissions[RoleAuthority.PARTICIPANT] = sequenceOf( - Permission.SUBJECT_READ, - Permission.SUBJECT_UPDATE, - Permission.MEASUREMENT_CREATE, - Permission.MEASUREMENT_READ - ) - - /* Inactive participant */ - // Doesn't have any permissions - rolePermissions[RoleAuthority.INACTIVE_PARTICIPANT] = emptySequence() - - // invert map - return rolePermissions.asSequence() - .flatMap { (role, permissionSeq) -> - permissionSeq.map { p -> Pair(p, role) } - } - .groupingBy { (p, _) -> p } - .foldTo( - EnumMap(Permission::class.java), - initialValueSelector = { _, (_, role) -> enumSetOf(role) }, - operation = { _, set, (_, role) -> - set += role - set - } - ) - } - - private fun Sequence.limitEntityOperations( - entity: Permission.Entity, - vararg operations: Permission.Operation - ): Sequence { - val operationSet = enumSetOf(*operations) - return filter { p: Permission -> p.entity != entity || p.operation in operationSet } - } - - private fun Sequence.exclude(vararg permissions: Permission): Sequence { - val permissionSet = enumSetOf(*permissions) - return filter { it !in permissionSet } - } - - private fun Sequence.excludeEntities(vararg entities: Permission.Entity): Sequence { - val entitySet = enumSetOf(*entities) - return filter { it.entity !in entitySet } - } - - private inline fun > enumSetOf(vararg values: T): EnumSet = EnumSet.noneOf( - T::class.java - ).apply { - values.forEach { add(it) } - } - } + fun RoleAuthority.mayBeGranted(permission: Permission): Boolean } + diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt new file mode 100644 index 000000000..b9fbaf888 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt @@ -0,0 +1,307 @@ +package org.radarbase.auth.authorization + +import org.radarbase.auth.token.RadarToken +import java.util.* + +class MPAuthorizationOracle( + private val relationService: EntityRelationService +) : AuthorizationOracle { + /** + * Whether [identity] has permission [permission], regarding given [entity]. An additional + * [entityScope] can be provided to check whether the permission is also valid regarding that + * scope. The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @return true if identity has permission, false otheriwse + */ + override fun hasPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean { + if (permission.scope() !in identity.scopes) return false + + if (identity.isClientCredentials) return true + + return identity.roles.any { + it.hasPermission(identity, permission, entity, entityScope) + } + } + + /** + * Whether given [identity] would have the [permission] scope in any of its roles. This doesn't + * check whether [identity] has access to a specific entity or global access. + * @return true if identity has scope, false otherwise + */ + override fun hasScope(identity: RadarToken, permission: Permission): Boolean { + if (permission.scope() !in identity.scopes) return false + + if (identity.isClientCredentials) return true + + return identity.roles.any { it.role.mayBeGranted(permission) } + } + + /** + * Return a list of referents, per scope, that given [identity] has given [permission] on. + * The GLOBAL scope does not have any referents, so that will always return an empty list. + * The ORGANIZATION scope will give a list of organization names, and the PROJECT scope a list + * of project names. If identity has no role with given permission, this will return an empty + * map. + */ + override fun referentsByScope( + identity: RadarToken, + permission: Permission + ): AuthorityReferenceSet { + var global = false + val organizations = mutableSetOf() + val projects = mutableSetOf() + val personalProjects = mutableSetOf() + + identity.roles.forEach { + if (it.role.mayBeGranted(permission)) { + when (it.role.scope) { + RoleAuthority.Scope.GLOBAL -> global = true + RoleAuthority.Scope.ORGANIZATION -> organizations.add(it.referent!!) + RoleAuthority.Scope.PROJECT -> { + if (it.role.isPersonal) { + personalProjects.add(it.referent!!) + } else { + projects.add(it.referent!!) + } + } + } + } + } + + return AuthorityReferenceSet( + global = global, + organizations = organizations, + projects = projects, + personalProjects = personalProjects, + ) + } + + /** + * Check if this role has may have given permission associated with it. + * @param permission the permission to check + * @return true if this role has given permission associated with it, false otherwise + */ + override fun RoleAuthority.mayBeGranted(permission: Permission) = this in allowedRoles(permission) + + /** + * Whether the current role from [identity] has [permission] over given [entity] in + * [entityScope] in any way. + */ + private fun AuthorityReference.hasPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean { + if (!role.mayBeGranted(permission)) return false + if (role.scope == RoleAuthority.Scope.GLOBAL) return true + // if no entity scope is available and the role scope is not global, no matching authority + // can be found. + val minEntityScope = entity.minimumEntityOrNull() ?: return false + return hasAuthority(identity, permission, entity, entityScope) && + (entityScope == minEntityScope || + hasAuthority(identity, permission, entity, minEntityScope)) + } + + /** + * Whether the current role from [identity] has a specific authority with [permission] + * over given [entity] in [entityScope] + */ + private fun AuthorityReference.hasAuthority( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean = when (entityScope) { + Permission.Entity.MEASUREMENT -> hasAuthority(identity, permission, entity, + Permission.Entity.SOURCE + ) + Permission.Entity.SOURCE -> (!role.isPersonal || + // no specific source is mentioned -> just check the subject + entity.source == null || + entity.source in identity.sources) && + hasAuthority(identity, permission, entity, Permission.Entity.SUBJECT) + Permission.Entity.SUBJECT -> (!role.isPersonal || + entity.subject == identity.subject) && + hasAuthority(identity, permission, entity, Permission.Entity.PROJECT) + Permission.Entity.PROJECT -> when (role.scope) { + RoleAuthority.Scope.PROJECT -> referent == entity.project + RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent + else -> false + } + Permission.Entity.ORGANIZATION -> when (role.scope) { + RoleAuthority.Scope.PROJECT -> referent == entity.project || entity.organizationContainsProject(referent!!) + RoleAuthority.Scope.ORGANIZATION -> entity.findOrganization() == referent + else -> false + } + Permission.Entity.USER -> entity.user == identity.username || !role.isPersonal + else -> true + } + + private fun EntityDetails.findOrganization(): String? { + organization?.let { return it } + val p = project ?: return null + return relationService.findOrganizationOfProject(p) + .also { this.organization = it } + } + + private fun EntityDetails.organizationContainsProject(targetProject: String): Boolean { + val org = findOrganization() ?: return false + return relationService.organizationContainsProject(org, targetProject) + } + + /** + * Created by dverbeec on 22/09/2017. + */ + companion object Permissions { + /** + * Get the permission matrix. + * + * + * The permission matrix maps each [Permission] to a set of authorities that have that + * permission. + * @return An unmodifiable view of the permission matrix. + */ + @JvmStatic + val permissionMatrix: Map> = createPermissions() + + /** + * Look up the allowed authorities for a given permission. Authorities are String constants that + * appear in [RoleAuthority]. + * @param permission The permission to look up. + * @return An unmodifiable view of the set of allowed authorities. + */ + @JvmStatic + fun allowedRoles(permission: Permission): Set { + return permissionMatrix[permission] ?: emptySet() + } + + /** + * Static permission matrix based on the currently agreed upon security rules. + */ + private fun createPermissions(): Map> { + val rolePermissions: MutableMap> = + EnumMap(RoleAuthority::class.java) + + // System admin can do everything. + rolePermissions[RoleAuthority.SYS_ADMIN] = Permission.values().asSequence() + + // Organization admin can do most things, but not view subjects or measurements + rolePermissions[RoleAuthority.ORGANIZATION_ADMIN] = Permission.values().asSequence() + .exclude( + Permission.ORGANIZATION_CREATE, + Permission.SOURCEDATA_CREATE, + Permission.SOURCETYPE_CREATE + ) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.MEASUREMENT + ) + + // for all authorities except for SYS_ADMIN, the authority is scoped to a project, which + // is checked elsewhere + // Project Admin - has all currently defined permissions except creating new projects + // Note: from radar-auth:0.5.7 we allow PROJECT_ADMIN to create measurements. + // This can be done by uploading data through the web application. + rolePermissions[RoleAuthority.PROJECT_ADMIN] = Permission.values().asSequence() + .exclude(Permission.PROJECT_CREATE) + .excludeEntities(Permission.Entity.AUDIT, Permission.Entity.AUTHORITY) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + + /* Project Owner */ + // CRUD operations on subjects to allow enrollment + rolePermissions[RoleAuthority.PROJECT_OWNER] = Permission.values().asSequence() + .exclude(Permission.PROJECT_CREATE) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + + /* Project affiliate */ + // Create, read and update participant (no delete) + rolePermissions[RoleAuthority.PROJECT_AFFILIATE] = Permission.values().asSequence() + .exclude(Permission.SUBJECT_DELETE) + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + .limitEntityOperations(Permission.Entity.ORGANIZATION, Permission.Operation.READ) + .limitEntityOperations(Permission.Entity.PROJECT, Permission.Operation.READ) + + /* Project analyst */ + // Can read everything except users, authorities and audits + rolePermissions[RoleAuthority.PROJECT_ANALYST] = Permission.values().asSequence() + .excludeEntities( + Permission.Entity.AUDIT, + Permission.Entity.AUTHORITY, + Permission.Entity.USER + ) + // Can add metadata to sources, only read other things. + .filter { p -> + p.operation == Permission.Operation.READ || + p == Permission.SUBJECT_UPDATE + } + + /* Participant */ + // Can update and read own data and can read and write own measurements + rolePermissions[RoleAuthority.PARTICIPANT] = sequenceOf( + Permission.SUBJECT_READ, + Permission.SUBJECT_UPDATE, + Permission.MEASUREMENT_CREATE, + Permission.MEASUREMENT_READ + ) + + /* Inactive participant */ + // Doesn't have any permissions + rolePermissions[RoleAuthority.INACTIVE_PARTICIPANT] = emptySequence() + + // invert map + return rolePermissions.asSequence() + .flatMap { (role, permissionSeq) -> + permissionSeq.map { p -> Pair(p, role) } + } + .groupingBy { (p, _) -> p } + .foldTo( + EnumMap(Permission::class.java), + initialValueSelector = { _, (_, role) -> enumSetOf(role) }, + operation = { _, set, (_, role) -> + set += role + set + } + ) + } + + private fun Sequence.limitEntityOperations( + entity: Permission.Entity, + vararg operations: Permission.Operation + ): Sequence { + val operationSet = enumSetOf(*operations) + return filter { p: Permission -> p.entity != entity || p.operation in operationSet } + } + + private fun Sequence.exclude(vararg permissions: Permission): Sequence { + val permissionSet = enumSetOf(*permissions) + return filter { it !in permissionSet } + } + + private fun Sequence.excludeEntities(vararg entities: Permission.Entity): Sequence { + val entitySet = enumSetOf(*entities) + return filter { it.entity !in entitySet } + } + + private inline fun > enumSetOf(vararg values: T): EnumSet = EnumSet.noneOf( + T::class.java + ).apply { + values.forEach { add(it) } + } + } +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt index bb937e13d..7a617a6d9 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/RoleAuthority.kt @@ -1,9 +1,6 @@ package org.radarbase.auth.authorization import java.io.Serializable -import java.util.* -import java.util.stream.Collector -import java.util.stream.Collectors /** * Constants for Spring Security authorities. @@ -27,13 +24,6 @@ enum class RoleAuthority( GLOBAL, ORGANIZATION, PROJECT } - /** - * Check if this role has may have given permission associated with it. - * @param permission the permission to check - * @return true if this role has given permission associated with it, false otherwise - */ - fun mayBeGranted(permission: Permission) = this in AuthorizationOracle.allowedRoles(permission) - companion object { const val SYS_ADMIN_AUTHORITY = "ROLE_SYS_ADMIN" @@ -45,11 +35,11 @@ enum class RoleAuthority( * @throws NullPointerException if given authority is null. */ @JvmStatic - fun valueOfAuthority(authority: String): RoleAuthority { - val upperAuthority = authority.uppercase() - require(upperAuthority.startsWith("ROLE_")) { "Cannot map role without 'ROLE_' prefix" } - return valueOf(upperAuthority.substring(5)) - } + fun valueOfAuthority(authority: String): RoleAuthority = valueOf( + authority + .uppercase() + .removePrefix("ROLE_") + ) /** * Find role authority based on authority name. diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt index 4849dfcf2..0cadf2c8f 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/ECPEMCertificateParser.kt @@ -3,6 +3,7 @@ package org.radarbase.auth.jwks import com.auth0.jwt.algorithms.Algorithm import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_EC import org.radarbase.auth.jwks.PEMCertificateParser.Companion.parsePublicKey +import java.security.interfaces.ECPublicKey class ECPEMCertificateParser : PEMCertificateParser { override val jwtAlgorithm: String @@ -10,8 +11,9 @@ class ECPEMCertificateParser : PEMCertificateParser { override val keyHeader: String get() = "-----BEGIN EC PUBLIC KEY-----" - override fun parseAlgorithm(publicKey: String): Algorithm = - Algorithm.ECDSA256(publicKey.parsePublicKey(keyFactoryType), null) + override fun parseAlgorithm(publicKey: String): Algorithm = publicKey + .parsePublicKey(keyFactoryType) + .toAlgorithm() override val keyFactoryType: String get() = ALGORITHM_EC diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/Extensions.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/Extensions.kt new file mode 100644 index 000000000..bdc9cc51b --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/Extensions.kt @@ -0,0 +1,27 @@ +package org.radarbase.auth.jwks + +import com.auth0.jwt.algorithms.Algorithm +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey + +fun RSAPublicKey.toAlgorithm(hashSize: RSAJsonWebKey.HashSize = RSAJsonWebKey.HashSize.RS256): Algorithm = when (hashSize) { + RSAJsonWebKey.HashSize.RS256 -> Algorithm.RSA256(this, null) + RSAJsonWebKey.HashSize.RS384 -> Algorithm.RSA384(this, null) + RSAJsonWebKey.HashSize.RS512 -> Algorithm.RSA512(this, null) +} + +fun ECPublicKey.toAlgorithm(): Algorithm { + val keySize = when (val orderLength = params.order.bitLength()) { + in 0 .. 256 -> ECDSAJsonWebKey.Curve.ES256 + in 257 .. 384 -> ECDSAJsonWebKey.Curve.ES384 + in 385 .. 521 -> ECDSAJsonWebKey.Curve.ES512 + else -> throw IllegalArgumentException("Unknown ECDSA order length $orderLength") + } + return toAlgorithm(keySize) +} + +fun ECPublicKey.toAlgorithm(keySize: ECDSAJsonWebKey.Curve): Algorithm = when (keySize) { + ECDSAJsonWebKey.Curve.ES256 -> Algorithm.ECDSA256(this, null) + ECDSAJsonWebKey.Curve.ES384 -> Algorithm.ECDSA384(this, null) + ECDSAJsonWebKey.Curve.ES512 -> Algorithm.ECDSA512(this, null) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt index 7ec7f72c1..e9f2dc9e8 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JsonWebKey.kt @@ -8,9 +8,10 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.radarbase.auth.exception.InvalidPublicKeyException +import java.lang.IllegalArgumentException /** - * Represents the JavaWebKey for token verification. + * Represents the OAuth 2.0 JsonWebKey for token verification. */ @Serializable(with = JavaWebKeyPolymorphicSerializer::class) sealed interface JsonWebKey { @@ -39,7 +40,7 @@ object JavaWebKeyPolymorphicSerializer : JsonContentPolymorphicSerializer KeySize.ES256 - "P-384" -> KeySize.ES384 - "P-521" -> KeySize.ES512 + "P-256" -> Curve.ES256 + "P-384" -> Curve.ES384 + "P-521", "P-512" -> Curve.ES512 else -> throw InvalidPublicKeyException("Unknown EC crv $crv") } } - enum class KeySize(val ecStdName: String) { + enum class Curve(val ecStdName: String) { ES256("secp256r1"), ES384("secp384r1"), ES512("secp521r1"); diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt index df610e0fe..4ce32ea67 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/JwkAlgorithmParser.kt @@ -40,50 +40,41 @@ class JwkAlgorithmParser( ?.parseAlgorithm(key.value) ?: throw TokenValidationException("Unsupported public key: $key") is RSAJsonWebKey -> try { - val kf: KeyFactory = KeyFactory.getInstance(ALGORITHM_RSA) + val keyFactory: KeyFactory = KeyFactory.getInstance(ALGORITHM_RSA) val publicKeySpec = RSAPublicKeySpec( BigInteger(1, Base64.getUrlDecoder().decode(key.n)), BigInteger(1, Base64.getUrlDecoder().decode(key.e)) ) - val publicKey = kf.generatePublic(publicKeySpec) as RSAPublicKey - when (key.keySize()) { - RSAJsonWebKey.KeySize.RS256 -> Algorithm.RSA256(publicKey, null) - RSAJsonWebKey.KeySize.RS384 -> Algorithm.RSA384(publicKey, null) - RSAJsonWebKey.KeySize.RS512 -> Algorithm.RSA512(publicKey, null) - } + (keyFactory.generatePublic(publicKeySpec) as RSAPublicKey) + .toAlgorithm(hashSize = key.keySize()) } catch (e: GeneralSecurityException) { throw InvalidPublicKeyException("Invalid public key", e) } - is ECDSAJsonWebKey -> - try { - val keyFactory = KeyFactory.getInstance(ALGORITHM_EC) - val keySize = key.keySize() - val ecPublicKeySpec = ECPublicKeySpec( - ECPoint( - BigInteger(1, Base64.getUrlDecoder().decode(key.x)), - BigInteger(1, Base64.getUrlDecoder().decode(key.y)) - ), - AlgorithmParameters.getInstance(ALGORITHM_EC).run { - init(ECGenParameterSpec(keySize.ecStdName)) - getParameterSpec(ECParameterSpec::class.java) - } - ) - val publicKey = keyFactory.generatePublic(ecPublicKeySpec) as ECPublicKey - when (keySize) { - ECDSAJsonWebKey.KeySize.ES256 -> Algorithm.ECDSA256(publicKey, null) - ECDSAJsonWebKey.KeySize.ES384 -> Algorithm.ECDSA384(publicKey, null) - ECDSAJsonWebKey.KeySize.ES512 -> Algorithm.ECDSA512(publicKey, null) + is ECDSAJsonWebKey -> try { + val keyFactory = KeyFactory.getInstance(ALGORITHM_EC) + val keySize = key.curve() + val ecPublicKeySpec = ECPublicKeySpec( + ECPoint( + BigInteger(1, Base64.getUrlDecoder().decode(key.x)), + BigInteger(1, Base64.getUrlDecoder().decode(key.y)) + ), + AlgorithmParameters.getInstance(ALGORITHM_EC).run { + init(ECGenParameterSpec(keySize.ecStdName)) + getParameterSpec(ECParameterSpec::class.java) } - } catch (e: NoSuchAlgorithmException) { - throw InvalidPublicKeyException("Invalid algorithm to generate key", e) - } catch (e: GeneralSecurityException) { - throw InvalidPublicKeyException("Invalid public key", e) - } + ) + (keyFactory.generatePublic(ecPublicKeySpec) as ECPublicKey) + .toAlgorithm(keySize = key.curve()) + } catch (e: NoSuchAlgorithmException) { + throw InvalidPublicKeyException("Invalid algorithm to generate key", e) + } catch (e: GeneralSecurityException) { + throw InvalidPublicKeyException("Invalid public key", e) + } } } override fun toString(): String = buildString(50) { - append("StringAlgorithmKeyLoader Unit = {}): JwtTokenVerifier { val verifier = JWT.require(this).run { - withClaimPresence(JwtRadarToken.SCOPE_CLAIM) + withClaimPresence(SCOPE_CLAIM) withAudience(resourceName) + builder() build() } return JwtTokenVerifier(name, verifier) diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt b/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt index 1c830a7da..2e93679f7 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwks/RSAPEMCertificateParser.kt @@ -3,6 +3,7 @@ package org.radarbase.auth.jwks import com.auth0.jwt.algorithms.Algorithm import org.radarbase.auth.jwks.JsonWebKey.Companion.ALGORITHM_RSA import org.radarbase.auth.jwks.PEMCertificateParser.Companion.parsePublicKey +import java.security.interfaces.RSAPublicKey class RSAPEMCertificateParser : PEMCertificateParser { override val keyFactoryType: String @@ -12,6 +13,7 @@ class RSAPEMCertificateParser : PEMCertificateParser { override val keyHeader: String get() = "-----BEGIN PUBLIC KEY-----" - override fun parseAlgorithm(publicKey: String): Algorithm = - Algorithm.RSA256(publicKey.parsePublicKey(keyFactoryType), null) + override fun parseAlgorithm(publicKey: String): Algorithm = publicKey + .parsePublicKey(keyFactoryType) + .toAlgorithm() } diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt deleted file mode 100644 index 592af763b..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtRadarToken.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.radarbase.auth.jwt - -import com.auth0.jwt.exceptions.JWTDecodeException -import com.auth0.jwt.interfaces.DecodedJWT -import org.radarbase.auth.authorization.RoleAuthority -import org.radarbase.auth.token.AbstractRadarToken -import org.radarbase.auth.token.AuthorityReference -import java.util.* -import kotlin.collections.ArrayList - -/** - * Implementation of [RadarToken] based on JWT tokens. - * - * Initialize this `JwtRadarToken` based on the [DecodedJWT]. All relevant - * information will be parsed at construction time and no reference to the [DecodedJWT] - * is kept. Therefore, modifying the passed in [DecodedJWT] after this has been - * constructed will **not** update this object. - * @param jwt the JWT token to use to initialize this object - */ -class JwtRadarToken(private val jwt: DecodedJWT) : AbstractRadarToken() { - override val roles: Set = (jwt.parseAuthorities() + jwt.parseRoles()).toSet() - override val scopes: Set - override val sources: List = jwt.listClaim(SOURCES_CLAIM) - override val grantType: String = jwt.stringClaim(GRANT_TYPE_CLAIM) - override val subject: String = jwt.subject ?: "" - override val issuedAt: Date = jwt.issuedAt - override val expiresAt: Date = jwt.expiresAt - override val audience: List = jwt.audience ?: emptyList() - override val token: String = jwt.token ?: "" - override val issuer: String = jwt.issuer ?: "" - override val type: String = jwt.type ?: "" - override val clientId: String = jwt.stringClaim(CLIENT_ID_CLAIM) - override val username: String = jwt.stringClaim(USER_NAME_CLAIM) - - init { - val scopeClaim = jwt.getClaim(SCOPE_CLAIM) - val scopeClaimString = scopeClaim.asString() - scopes = scopeClaimString?.parseScopes() - ?: jwt.listClaim(SCOPE_CLAIM).toSet() - } - - override fun getClaimString(name: String): String? { - return jwt.getClaim(name).asString() - } - - override fun getClaimList(name: String): List { - return try { - jwt.listClaim(name) - } catch (ex: JWTDecodeException) { - emptyList() - } - } - - companion object { - private const val AUTHORITIES_CLAIM = "authorities" - const val ROLES_CLAIM = "roles" - const val SCOPE_CLAIM = "scope" - const val SOURCES_CLAIM = "sources" - const val GRANT_TYPE_CLAIM = "grant_type" - const val CLIENT_ID_CLAIM = "client_id" - const val USER_NAME_CLAIM = "user_name" - - private fun DecodedJWT.listClaim(name: String): List = getClaim(name) - .asList(String::class.java) - ?.filterTo(ArrayList()) { s: String? -> !s.isNullOrBlank() } - ?: emptyList() - - private fun DecodedJWT.stringClaim(name: String) = getClaim(name) - .asString() - ?: "" - - private fun String.parseScopes() = split(' ') - .filterTo(mutableSetOf()) { it.isNotBlank() } - - private fun DecodedJWT.parseAuthorities(): Sequence = listClaim( - AUTHORITIES_CLAIM - ) - .asSequence() - .mapNotNull { RoleAuthority.valueOfAuthorityOrNull(it) } - .filter { it.scope == RoleAuthority.Scope.GLOBAL } - .map { AuthorityReference(it) } - - private fun DecodedJWT.parseRoles(): Sequence = listClaim(ROLES_CLAIM) - .asSequence() - .mapNotNull { input -> - val v = input.split(':') - try { - if (v.size == 1 || v[1].isEmpty()) { - AuthorityReference(v[0]) - } else { - AuthorityReference(v[1], v[0]) - } - } catch (ex: IllegalArgumentException) { - null - } - } - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt index 425679599..035f2f23c 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt @@ -1,10 +1,15 @@ package org.radarbase.auth.jwt +import com.auth0.jwt.exceptions.JWTDecodeException import com.auth0.jwt.exceptions.JWTVerificationException import com.auth0.jwt.exceptions.SignatureVerificationException +import com.auth0.jwt.interfaces.Claim +import com.auth0.jwt.interfaces.DecodedJWT import com.auth0.jwt.interfaces.JWTVerifier import org.radarbase.auth.authentication.TokenVerifier -import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.DataRadarToken import org.radarbase.auth.token.RadarToken import org.slf4j.LoggerFactory @@ -18,7 +23,7 @@ class JwtTokenVerifier( // Do not print full token with signature to avoid exposing valid token in logs. logger.debug("Verified JWT header {} and payload {}", jwt.header, jwt.payload) - JwtRadarToken(jwt) + jwt.toRadarToken() } catch (ex: Throwable) { when (ex) { is SignatureVerificationException -> logger.debug("Client presented a token with an incorrect signature.") @@ -31,5 +36,76 @@ class JwtTokenVerifier( companion object { private val logger = LoggerFactory.getLogger(JwtTokenVerifier::class.java) + + private const val AUTHORITIES_CLAIM = "authorities" + const val ROLES_CLAIM = "roles" + const val SCOPE_CLAIM = "scope" + const val SOURCES_CLAIM = "sources" + const val GRANT_TYPE_CLAIM = "grant_type" + private const val CLIENT_ID_CLAIM = "client_id" + private const val USER_NAME_CLAIM = "user_name" + + fun DecodedJWT.toRadarToken(): RadarToken { + val claims = claims + + return DataRadarToken( + roles = claims.parseRoles(), + scopes = claims.stringListClaim(SCOPE_CLAIM)?.toSet() ?: emptySet(), + sources = claims.stringListClaim(SOURCES_CLAIM) ?: emptyList(), + grantType = claims.stringClaim(GRANT_TYPE_CLAIM), + subject = subject, + issuedAt = issuedAtAsInstant, + expiresAt = expiresAtAsInstant, + audience = audience ?: emptyList(), + token = token, + issuer = issuer, + type = type, + clientId = claims.stringClaim(CLIENT_ID_CLAIM), + username = claims.stringClaim(USER_NAME_CLAIM), + ) + } + fun Map.stringListClaim(name: String): List? { + val claim = get(name) ?: return null + val claimList = try { + claim.asList(String::class.java) + } catch (ex: JWTDecodeException) { + // skip + null + } + val claims = claimList + ?: claim.asString()?.split(' ') + ?: return null + + return claims.mapNotNull { it?.trimNotEmpty() } + } + + fun Map.stringClaim(name: String): String? = get(name)?.asString() + ?.trimNotEmpty() + + private fun String.trimNotEmpty(): String? = trim() + .takeIf { it.isNotEmpty() } + + private fun Map.parseRoles(): Set = buildSet { + stringListClaim(AUTHORITIES_CLAIM)?.forEach { + val role = RoleAuthority.valueOfAuthorityOrNull(it) + if (role?.scope == RoleAuthority.Scope.GLOBAL) { + add(AuthorityReference(role)) + } + } + stringListClaim(ROLES_CLAIM)?.forEach { input -> + val v = input.split(':') + try { + add( + if (v.size == 1 || v[1].isEmpty()) { + AuthorityReference(v[0]) + } else { + AuthorityReference(v[1], v[0]) + } + ) + } catch (ex: IllegalArgumentException) { + // skip + } + } + } } } diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt deleted file mode 100644 index 5dc1df8a6..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/token/AbstractRadarToken.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.radarbase.auth.token - -/** - * Partial implementation of [RadarToken], providing a default implementation for the three - * permission checks. - */ -abstract class AbstractRadarToken : RadarToken { - override val isClientCredentials: Boolean - get() = CLIENT_CREDENTIALS == grantType - - override fun equals(other: Any?): Boolean { - if (other === this) return true - if (other?.javaClass != javaClass) return false - - other as AbstractRadarToken - return token == other.token - } - - override fun hashCode(): Int = token.hashCode() - - override fun toString(): String = buildString { - append("AbstractRadarToken{scopes=") - append(scopes) - append(", username='") - append(username) - append('\'') - append(", subject='") - append(subject) - append('\'') - append(", roles=") - append(roles) - append(", sources=") - append(sources) - append(", grantType='") - append(grantType) - append('\'') - append(", audience=") - append(audience) - append(", issuer='") - append(issuer) - append('\'') - append(", issuedAt=") - append(issuedAt) - append(", expiresAt=") - append(expiresAt) - append(", type='") - append(type) - append('\'') - append('}') - } - - companion object { - const val CLIENT_CREDENTIALS = "client_credentials" - } -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt new file mode 100644 index 000000000..52413a113 --- /dev/null +++ b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt @@ -0,0 +1,89 @@ +package org.radarbase.auth.token + +import org.radarbase.auth.authorization.AuthorityReference +import java.io.Serializable +import java.time.Instant + +/** + * Created by dverbeec on 10/01/2018. + */ +data class DataRadarToken( + /** + * Get all roles defined in this token. + * @return non-null set describing the roles defined in this token. + */ + override val roles: Set, + + /** + * Get a list of scopes assigned to this token. + * @return non-null list of scope names + */ + override val scopes: Set, + + /** + * Get a list of source names associated with this token. + * @return non-null list of source names + */ + override val sources: List = emptyList(), + + /** + * Get this token's OAuth2 grant type. + * @return grant type + */ + override val grantType: String?, + + /** + * Get the token subject. + * @return non-null subject + */ + override val subject: String? = null, + + /** + * Get the token username. + */ + override val username: String? = null, + + /** + * Get the date this token was issued. + * @return date this token was issued or null + */ + override val issuedAt: Instant? = null, + + /** + * Get the date this token expires. + * @return date this token expires or null + */ + override val expiresAt: Instant, + + /** + * Get the audience of the token. + * @return non-null list of resources that are allowed to accept the token + */ + override val audience: List = listOf(), + + /** + * Get a string representation of this token. + * @return string representation of this token + */ + override val token: String? = null, + + /** + * Get the issuer. + * @return issuer + */ + override val issuer: String? = null, + + /** + * Get the token type. + * @return token type. + */ + override val type: String? = null, + + /** + * Client that the token is associated to. + * @return client ID if set or null if unknown. + */ + override val clientId: String? = null, +): RadarToken, Serializable { + override fun copyWithRoles(roles: Set): DataRadarToken = copy(roles = roles) +} diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt index 4f93e99e8..52acb36d9 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/token/RadarToken.kt @@ -1,6 +1,7 @@ package org.radarbase.auth.token -import java.util.* +import org.radarbase.auth.authorization.AuthorityReference +import java.time.Instant /** * Created by dverbeec on 10/01/2018. @@ -26,32 +27,32 @@ interface RadarToken { /** * Get this token's OAuth2 grant type. - * @return non-null grant type + * @return grant type */ - val grantType: String + val grantType: String? /** * Get the token subject. * @return non-null subject */ - val subject: String + val subject: String? /** * Get the token username. */ - val username: String + val username: String? /** * Get the date this token was issued. * @return date this token was issued or null */ - val issuedAt: Date + val issuedAt: Instant? /** * Get the date this token expires. * @return date this token expires or null */ - val expiresAt: Date + val expiresAt: Instant /** * Get the audience of the token. @@ -61,41 +62,27 @@ interface RadarToken { /** * Get a string representation of this token. - * @return non-null string representation of this token + * @return string representation of this token */ - val token: String + val token: String? /** * Get the issuer. - * @return non-null issuer + * @return issuer */ - val issuer: String + val issuer: String? /** * Get the token type. - * @return non-null token type. + * @return token type. */ - val type: String + val type: String? /** * Client that the token is associated to. * @return client ID if set or null if unknown. */ - val clientId: String - - /** - * Get a token claim by name. - * @param name claim name. - * @return a claim value or null if none was found or the type was not a string. - */ - fun getClaimString(name: String): String? - - /** - * Get a token claim list by name. - * @param name claim name. - * @return a claim list of values or null if none was found or the type was not a string. - */ - fun getClaimList(name: String): List + val clientId: String? /** * Whether the current credentials were obtained with a OAuth 2.0 client credentials flow. @@ -103,4 +90,11 @@ interface RadarToken { * @return true if the client credentials flow was certainly used, false otherwise. */ val isClientCredentials: Boolean + get() = grantType == CLIENT_CREDENTIALS + + fun copyWithRoles(roles: Set): RadarToken + + companion object { + const val CLIENT_CREDENTIALS = "client_credentials" + } } diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt b/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt index 45cef3e19..ba9c901d8 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt @@ -2,59 +2,102 @@ package org.radarbase.auth.util import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Semaphore import java.time.Duration import java.time.Instant import java.util.concurrent.atomic.AtomicReference +internal typealias DeferredCache = CompletableDeferred> + /** * Caches a value with full support for coroutines. * Only one coroutine context will compute the value at a time, other coroutine contexts will wait * for it to finish. */ class CachedValue( - private val minAge: Duration = Duration.ofMinutes(1), - private val maxAge: Duration = Duration.ofHours(3), + /** Duration after which the cache is considered stale and should be refreshed. */ + val refreshDuration: Duration = Duration.ofMinutes(30), + /** Duration after which the cache may be refreshed if the cache does not fulfill a certain + * requirement. This should be shorter than [refreshDuration] to have effect. */ + val retryDuration: Duration = Duration.ofMinutes(1), + /** Time to wait for a lock to come free when an exception is set for the cache. */ + val exceptionLockDuration: Duration = Duration.ofSeconds(10), + /** + * Number of simultaneous computations that may occur. Increase if the time to computation + * is very variable. + */ + val maxSimultaneousCompute: Int = 1, private val compute: suspend () -> T, ) { - init { - require(minAge > Duration.ZERO) { "Cache fetch duration $minAge must be positive" } - require(maxAge >= minAge) { "Cache maximum age $maxAge must be at least fetch timeout $minAge" } - } private val cache = AtomicReference>>() + private val semaphore: Semaphore? = if (maxSimultaneousCompute > 1) { + Semaphore(maxSimultaneousCompute - 1) + } else { + null + } + + init { + require(retryDuration > Duration.ZERO) { "Cache fetch duration $retryDuration must be positive" } + require(refreshDuration >= retryDuration) { "Cache maximum age $refreshDuration must be at least fetch timeout $retryDuration" } + require(maxSimultaneousCompute > 0) { "At least one context must be able to compute the result" } + } /** * Get cached value. If the cache is expired, fetch it again. The first coroutine context - * that reaches this method will call [compute], others coroutine contexts will use the value + * that reaches this method will call [computeAndCache], others coroutine contexts will use the value * computed by the first. */ - suspend fun get(refresh: Boolean = false): CacheResult { - val deferred = raceForDeferred() + suspend fun get(retryCondition: (T) -> Boolean = { false }): CacheResult { + val deferredResult = raceForDeferred() + val deferred = deferredResult.value - val result: CacheContents + return if (deferredResult is CacheMiss) { + deferred.computeAndCache() + } else { + deferred.concurrentComputeAndCache() + ?: deferred.awaitCache(retryCondition) + } + } - if (deferred is CacheMiss) { - result = try { - CacheValue(compute()) - } catch (ex: Throwable) { - CacheError(ex) - } - deferred.value.complete(result) + private suspend fun DeferredCache.computeAndCache(): CacheResult { + val result = try { + val value = compute() + complete(CacheValue(value)) + value + } catch (ex: Throwable) { + complete(CacheError(ex)) + throw ex + } + return CacheMiss(result) + } + + private suspend fun DeferredCache.awaitCache(retry: (T) -> Boolean): CacheResult { + val result = await() + return if (result.isExpired(retry)) { + // Either no new coroutine context had updated the cache value, then update it to + // null. Otherwise, another suspend context is active and get() will await the + // result from that context + cache.compareAndSet(this, null) + get() } else { - result = deferred.value.await() - // If the result is expired, refetch. - if (result.isExpired(refresh)) { - // Either no new coroutine context had updated the cache value, then update it to - // null. Otherwise, another suspend context is active and get() will await the - // result from that context - cache.compareAndSet(deferred.value, null) - return get(refresh = false) - } + val value = result.getOrThrow() + CacheHit(value) } + } - return when (result) { - is CacheValue -> if (deferred is CacheMiss) CacheMiss(result.value) else CacheHit(result.value) - is CacheError -> throw result.exception + private suspend fun DeferredCache.concurrentComputeAndCache(): CacheResult? { + semaphore ?: return null + if (isCompleted || !semaphore.tryAcquire()) return null + + return try { + if (isCompleted) { + null + } else { + computeAndCache() + } + } finally { + semaphore.release() } } @@ -80,17 +123,17 @@ class CachedValue( return result } - private fun CacheContents<*>.isExpired(refresh: Boolean): Boolean = when { - this is CacheError && exception is CancellationException -> true - refresh || this is CacheError -> isExpired(minAge) - else -> isExpired(maxAge) + private fun CacheContents.isExpired(retry: (T) -> Boolean): Boolean = when { + this is CacheError -> exception is CancellationException || isExpired(exceptionLockDuration) + this is CacheValue && retry(value) -> isExpired(retryDuration) + else -> isExpired(refreshDuration) } fun clear() { cache.set(null) } - private sealed class CacheContents { + internal sealed class CacheContents { val time: Instant = Instant.now() @Volatile @@ -104,6 +147,11 @@ class CachedValue( } else -> false } + + fun getOrThrow() = when (this) { + is CacheValue -> value + is CacheError -> throw exception + } } private class CacheError( diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt b/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt index 210a1210a..23d6490f2 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt @@ -2,19 +2,34 @@ package org.radarbase.auth.util import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.consume +import kotlin.coroutines.CoroutineContext -internal suspend fun Iterable.forkJoin(convert: suspend (T) -> R): List = coroutineScope { - map { t -> async { convert(t) } } +/** + * Transform each value in the iterable in a separate coroutine and await termination. + */ +internal suspend inline fun Iterable.forkJoin( + coroutineContext: CoroutineContext = Dispatchers.Default, + crossinline transform: suspend CoroutineScope.(T) -> R +): List = coroutineScope { + map { t -> async(coroutineContext) { transform(t) } } .awaitAll() } -internal suspend fun consumeFirst(producer: suspend CoroutineScope.(SendChannel) -> Unit): T = coroutineScope { +/** + * Consume the first value produced by the producer on its provided channel. Once a value is sent + * by the producer, its coroutine is cancelled. + * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not + * produce any values. + */ +internal suspend inline fun consumeFirst( + coroutineContext: CoroutineContext = Dispatchers.Default, + crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit +): T = coroutineScope { val channel = Channel() - val producerJob = launch { - producer(channel) + val producerJob = launch(coroutineContext) { + producer(channel::send) channel.close() } @@ -22,3 +37,12 @@ internal suspend fun consumeFirst(producer: suspend CoroutineScope.(SendCha producerJob.cancel() result } + +internal operator fun Set.plus(elements: Set): Set = when { + isEmpty() -> elements + elements.isEmpty() -> this + else -> buildSet(size + elements.size) { + addAll(this) + addAll(elements) + } +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java b/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java index eb19cf3ac..3c6314932 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java +++ b/radar-auth/src/test/java/org/radarbase/auth/authentication/TokenValidatorTest.java @@ -31,7 +31,7 @@ class TokenValidatorTest { private TokenValidator validator; @BeforeAll - public static void loadToken() throws Exception { + public static void loadToken() { wireMockServer = new WireMockServer(new WireMockConfiguration() .port(WIREMOCK_PORT)); wireMockServer.start(); @@ -43,14 +43,15 @@ public static void loadToken() throws Exception { */ @BeforeEach public void setUp() { - wireMockServer.stubFor(get(urlEqualTo(TokenTestUtils.PUBLIC_KEY)).willReturn(aResponse() - .withStatus(200) - .withHeader("Content-type", TokenTestUtils.APPLICATION_JSON) - .withBody(TokenTestUtils.PUBLIC_KEY_BODY))); + wireMockServer.stubFor(get(urlEqualTo(TokenTestUtils.PUBLIC_KEY_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-type", TokenTestUtils.APPLICATION_JSON) + .withBody(TokenTestUtils.PUBLIC_KEY_BODY))); var algorithmParser = new JwkAlgorithmParser(List.of(new RSAPEMCertificateParser())); var verifierLoader = new JwksTokenVerifierLoader( - "http://localhost:" + WIREMOCK_PORT + TokenTestUtils.PUBLIC_KEY, + "http://localhost:" + WIREMOCK_PORT + TokenTestUtils.PUBLIC_KEY_PATH, "unit_test", algorithmParser ); @@ -69,24 +70,24 @@ public static void tearDown() { @Test void testValidToken() { - validator.authenticateBlocking(TokenTestUtils.VALID_RSA_TOKEN); + validator.validateBlocking(TokenTestUtils.VALID_RSA_TOKEN); } @Test void testIncorrectAudienceToken() { assertThrows(TokenValidationException.class, - () -> validator.authenticateBlocking(TokenTestUtils.INCORRECT_AUDIENCE_TOKEN)); + () -> validator.validateBlocking(TokenTestUtils.INCORRECT_AUDIENCE_TOKEN)); } @Test void testExpiredToken() { assertThrows(TokenValidationException.class, - () -> validator.authenticateBlocking(TokenTestUtils.EXPIRED_TOKEN)); + () -> validator.validateBlocking(TokenTestUtils.EXPIRED_TOKEN)); } @Test void testIncorrectAlgorithmToken() { assertThrows(TokenValidationException.class, - () -> validator.authenticateBlocking(TokenTestUtils.INCORRECT_ALGORITHM_TOKEN)); + () -> validator.validateBlocking(TokenTestUtils.INCORRECT_ALGORITHM_TOKEN)); } } diff --git a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java deleted file mode 100644 index 311685383..000000000 --- a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.radarbase.auth.authorization; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.radarbase.auth.authorization.Permission.Entity; -import org.radarbase.auth.exception.NotAuthorizedException; -import org.radarbase.auth.jwt.JwtRadarToken; -import org.radarbase.auth.token.AbstractRadarTokenTest; -import org.radarbase.auth.token.RadarToken; -import org.radarbase.auth.util.TokenTestUtils; - -import java.security.GeneralSecurityException; -import java.util.Arrays; -import java.util.Collection; -import java.util.Map.Entry; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * Created by dverbeec on 25/09/2017. - */ -class RadarAuthorizationTest { - private AuthorizationOracle oracle; - - @BeforeEach - public void setUp() { - this.oracle = new AuthorizationOracle( - new AbstractRadarTokenTest.MockEntityRelationService()); - } - - @Test - void testCheckPermissionOnProject() throws NotAuthorizedException { - String project = "PROJECT1"; - // let's get all permissions a project admin has - Set permissions = AuthorizationOracle.Permissions.getPermissionMatrix() - .entrySet().stream() - .filter(e -> e.getValue().contains(RoleAuthority.PROJECT_ADMIN)) - .map(Entry::getKey) - .collect(Collectors.toSet()); - RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); - for (Permission p : permissions) { - oracle.checkPermission(token, p, e -> e.project(project)); - } - - Set notPermitted = AuthorizationOracle.Permissions.getPermissionMatrix() - .entrySet().stream() - .filter(e -> !e.getValue().contains(RoleAuthority.PROJECT_ADMIN)) - .map(Entry::getKey) - .collect(Collectors.toSet()); - - notPermitted.forEach(p -> assertNotAuthorized(() -> - oracle.checkPermission(token, p, e -> e.project(project)), - String.format("Token should not have permission %s on project %s", p, project))); - } - - @Test - void testCheckPermissionOnOrganization() throws NotAuthorizedException { - JwtRadarToken token = new JwtRadarToken(TokenTestUtils.ORGANIZATION_ADMIN_TOKEN); - assertNotAuthorized(() -> oracle.checkPermission(token, Permission.ORGANIZATION_CREATE, - e -> e.organization("main")), - "Token should not be able to create organization"); - oracle.checkPermission(token, Permission.PROJECT_CREATE, e -> e.organization("main")); - oracle.checkPermission(token, Permission.SUBJECT_CREATE, - e -> e.organization("main").project("PROJECT1")); - assertNotAuthorized(() -> oracle.checkPermission(token, Permission.PROJECT_CREATE, - e -> e.organization("other")), - "Token should not be able to create project in other organization"); - assertNotAuthorized(() -> oracle.checkPermission( - token, Permission.SUBJECT_CREATE, - e -> e.organization("other").project("PROJECT1")), - "Token should not be able to create subject in other organization"); - } - - @Test - void testCheckPermission() throws NotAuthorizedException { - RadarToken token = new JwtRadarToken(TokenTestUtils.SUPER_USER_TOKEN); - for (Permission p : Permission.values()) { - oracle.checkPermission(token, p); - } - } - - @Test - void testCheckPermissionOnSelf() throws NotAuthorizedException { - String project = "PROJECT2"; - // this token is participant in PROJECT2 - RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); - String subject = token.getSubject(); - for (Permission p : Arrays.asList(Permission.MEASUREMENT_CREATE, - Permission.MEASUREMENT_READ, Permission.SUBJECT_UPDATE, Permission.SUBJECT_READ)) { - oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); - } - } - - @Test - void testCheckPermissionOnOtherSubject() { - // is only participant in project2, so should not have any permission on another subject - String project = "PROJECT2"; - // this token is participant in PROJECT2 - RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); - String other = "other-subject"; - Stream.of(Permission.values()) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission(token, p, e -> e.project(project).subject(other)), - "Token should not have permission " + p + " on another subject")); - } - - @Test - void testCheckPermissionOnSubject() throws NotAuthorizedException { - // project admin should have all permissions on subject in his project - String project = "PROJECT1"; - // this token is participant in PROJECT2 - RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); - String subject = "some-subject"; - Set permissions = Stream.of(Permission.values()) - .filter(p -> p.getEntity() == Permission.Entity.SUBJECT) - .collect(Collectors.toSet()); - for (Permission p : permissions) { - oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); - } - } - - @Test - void testMultipleRolesInProjectToken() throws NotAuthorizedException { - String project = "PROJECT2"; - RadarToken token = new JwtRadarToken(TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN); - String subject = "some-subject"; - Set permissions = Stream.of(Permission.values()) - .filter(p -> p.getEntity() == Permission.Entity.SUBJECT) - .collect(Collectors.toSet()); - for (Permission p : permissions) { - oracle.checkPermission(token, p, e -> e.project(project).subject(subject)); - } - } - - @Test - void testCheckPermissionOnSource() { - String project = "PROJECT2"; - // this token is participant in PROJECT2 - RadarToken token = new JwtRadarToken(TokenTestUtils.PROJECT_ADMIN_TOKEN); - String subject = "some-subject"; - String source = "source-1"; - - Stream.of(Permission.values()) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission( - token, p, e -> e.project(project).subject(subject).source(source)), - "Token should not have permission " + p + " on another subject")); - } - - @Test - void testCheckPermissionOnOwnSource() throws NotAuthorizedException { - String project = "PROJECT2"; - // this token is participant in PROJECT2 - RadarToken token = new JwtRadarToken(TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN); - String subject = token.getSubject(); - String source = "source-1"; // source to use - - Set permissions = Stream.of(Permission.values()) - .filter(p -> p.getEntity() == Entity.MEASUREMENT) - .collect(Collectors.toSet()); - - for (Permission p : permissions) { - oracle.checkPermission( - token, p, e -> e.project(project).subject(subject).source(source)); - } - } - - @Test - void testScopeOnlyToken() throws NotAuthorizedException { - RadarToken token = new JwtRadarToken(TokenTestUtils.SCOPE_TOKEN); - // test that we can do the things we have a scope for - Collection scope = Arrays.asList( - Permission.SUBJECT_READ, Permission.SUBJECT_CREATE, Permission.PROJECT_READ, - Permission.MEASUREMENT_CREATE); - - for (Permission p : scope) { - oracle.checkPermission(token, p); - oracle.checkPermission(token, p, e -> e.project("")); - oracle.checkPermission(token, p, e -> e.project("").subject("")); - oracle.checkPermission(token, p, e -> e.project("").subject("").source("")); - } - - // test we can do nothing else, for each of the checkPermission methods - Stream.of(Permission.values()) - .filter(p -> !scope.contains(p)) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission(token, p), - "Permission " + p + " is granted but not in scope.")); - - Stream.of(Permission.values()) - .filter(p -> !scope.contains(p)) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission(token, p, e -> e.project("")), - "Permission " + p + " is granted but not in scope.")); - - Stream.of(Permission.values()) - .filter(p -> !scope.contains(p)) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission(token, p, e -> e.project("").subject("")), - "Permission " + p + " is granted but not in scope.")); - - Stream.of(Permission.values()) - .filter(p -> !scope.contains(p)) - .forEach(p -> assertNotAuthorized( - () -> oracle.checkPermission(token, p, - e -> e.project("").subject("").source("")), - "Permission " + p + " is granted but not in scope.")); - } - - private static void assertNotAuthorized(AuthorizationCheck supplier, String message) { - assertThrows(NotAuthorizedException.class, supplier::check, message); - } - - @FunctionalInterface - interface AuthorizationCheck { - void check() throws GeneralSecurityException; - } -} diff --git a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt new file mode 100644 index 000000000..c6e2d71ce --- /dev/null +++ b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt @@ -0,0 +1,205 @@ +package org.radarbase.auth.authorization + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.radarbase.auth.authorization.MPAuthorizationOracle.Permissions.permissionMatrix +import org.radarbase.auth.jwt.JwtTokenVerifier.Companion.toRadarToken +import org.radarbase.auth.token.AbstractRadarTokenTest.MockEntityRelationService +import org.radarbase.auth.token.RadarToken +import org.radarbase.auth.util.TokenTestUtils + +/** + * Created by dverbeec on 25/09/2017. + */ +internal class RadarAuthorizationTest { + private lateinit var oracle: AuthorizationOracle + @BeforeEach + fun setUp() { + oracle = MPAuthorizationOracle( + MockEntityRelationService() + ) + } + + @Test + fun testCheckPermissionOnProject() { + val project = "PROJECT1" + // let's get all permissions a project admin has + val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT1") + + permissionMatrix.asSequence() + .filter { (_, roles) -> RoleAuthority.PROJECT_ADMIN in roles } + .map { (p, _) -> p } + .distinct() + .forEach { p -> assertTrue(oracle.hasPermission(token, p, entity)) } + + permissionMatrix.asSequence() + .filter { (_, roles) -> RoleAuthority.PROJECT_ADMIN !in roles } + .map { (p, _) -> p } + .distinct() + .forEach { p -> + assertFalse( + oracle.hasPermission(token, p, entity), + String.format("Token should not have permission %s on project %s", p, project), + ) + } + } + + @Test + fun testCheckPermissionOnOrganization() { + val token = TokenTestUtils.ORGANIZATION_ADMIN_TOKEN.toRadarToken() + val entity = EntityDetails(organization = "main") + assertFalse( + oracle.hasPermission(token, Permission.ORGANIZATION_CREATE, entity), + "Token should not be able to create organization" + ) + assertTrue(oracle.hasPermission(token, Permission.PROJECT_CREATE, entity)) + assertTrue( + oracle.hasPermission( + token, + Permission.SUBJECT_CREATE, + entity.copy(project = "PROJECT1"), + ), + ) + assertFalse( + oracle.hasPermission( + token, + Permission.PROJECT_CREATE, + EntityDetails(organization = "otherOrg"), + ), + "Token should not be able to create project in other organization", + ) + assertFalse( + oracle.hasPermission( + token, + Permission.SUBJECT_CREATE, + EntityDetails(organization = "otherOrg"), + ), + "Token should not be able to create subject in other organization" + ) + } + + @Test + fun testCheckPermission() { + val token: RadarToken = TokenTestUtils.SUPER_USER_TOKEN.toRadarToken() + for (p in Permission.values()) { + assertTrue(oracle.hasGlobalPermission(token, p)) + } + } + + @Test + fun testCheckPermissionOnSelf() { + // this token is participant in PROJECT2 + val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT2", subject = token.subject) + listOf( + Permission.MEASUREMENT_CREATE, + Permission.MEASUREMENT_READ, + Permission.SUBJECT_UPDATE, + Permission.SUBJECT_READ + ).forEach { p -> assertTrue(oracle.hasPermission(token, p, entity)) } + } + + @Test + fun testCheckPermissionOnOtherSubject() { + // is only participant in project2, so should not have any permission on another subject + val entity = EntityDetails(project = "PROJECT2", subject = "other-subject") + // this token is participant in PROJECT2 + val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() + Permission.values().forEach { p -> + assertFalse( + oracle.hasPermission(token, p, entity), + "Token should not have permission $p on another subject", + ) + } + } + + @Test + fun testCheckPermissionOnSubject() { + // project admin should have all permissions on subject in his project + // this token is participant in PROJECT2 + val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT1", subject = "some-subject") + Permission.values() + .asSequence() + .filter { p -> p.entity === Permission.Entity.SUBJECT } + .forEach { p -> + assertTrue(oracle.hasPermission(token, p, entity)) + } + } + + @Test + fun testMultipleRolesInProjectToken() { + val token: RadarToken = TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT2", subject = "some-subject") + Permission.values() + .asSequence() + .filter { p -> p.entity === Permission.Entity.SUBJECT } + .forEach { p -> + assertTrue(oracle.hasPermission(token, p, entity)) + } + } + + @Test + fun testCheckPermissionOnSource() { + // this token is participant in PROJECT2 + val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT2", subject = "some-subject", source = "source-1") + Permission.values() + .forEach { p: Permission -> + assertFalse( + oracle.hasPermission(token, p, entity), + "Token should not have permission $p on another subject", + ) + } + } + + @Test + fun testCheckPermissionOnOwnSource() { + val token: RadarToken = TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN.toRadarToken() + val entity = EntityDetails(project = "PROJECT2", subject = token.subject, source = "source-1") + Permission.values() + .asSequence() + .filter { p -> p.entity === Permission.Entity.MEASUREMENT } + .forEach { p -> + assertTrue(oracle.hasPermission(token, p, entity)) + } + } + + @Test + fun testScopeOnlyToken() { + val token: RadarToken = TokenTestUtils.SCOPE_TOKEN.toRadarToken() + // test that we can do the things we have a scope for + val entities = listOf( + EntityDetails.global, + EntityDetails(project = "PROJECT1"), + EntityDetails(project = "PROJECT1", subject = ""), + EntityDetails(project = "PROJECT1", subject = "", source = ""), + ) + listOf( + Permission.SUBJECT_READ, + Permission.SUBJECT_CREATE, + Permission.PROJECT_READ, + Permission.MEASUREMENT_CREATE + ).forEach { p -> + entities.forEach { e -> + assertTrue(oracle.hasPermission(token, p, e)) + } + } + + // test we can do nothing else, for each of the checkPermission methods + Permission.values() + .asSequence() + .filter { p -> p.scope() !in token.scopes } + .forEach { p -> + entities.forEach { e -> + assertFalse( + oracle.hasPermission(token, p, e), + "Permission $p is granted but not in scope.", + ) + } + } + } +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java deleted file mode 100644 index 09aba8250..000000000 --- a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.java +++ /dev/null @@ -1,256 +0,0 @@ -package org.radarbase.auth.token; - -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.radarbase.auth.authorization.AuthorizationOracle; -import org.radarbase.auth.authorization.EntityRelationService; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.radarbase.auth.authorization.Permission.MEASUREMENT_CREATE; -import static org.radarbase.auth.authorization.RoleAuthority.PARTICIPANT; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; -import static org.radarbase.auth.token.AbstractRadarToken.CLIENT_CREDENTIALS; - -public class AbstractRadarTokenTest { - private AuthorizationOracle oracle; - private MockToken token; - - static class MockToken extends AbstractRadarToken { - private final Set roles = new HashSet<>(); - private final List sources = new ArrayList<>(); - private final Set scopes = new LinkedHashSet<>(); - private String grantType = "refresh_token"; - private String subject = ""; - - @Override - public Set getRoles() { - return roles; - } - - @Override - public Set getScopes() { - return scopes; - } - - @Override - public List getSources() { - return sources; - } - - @Override - public String getGrantType() { - return grantType; - } - - @Override - public String getSubject() { - return subject; - } - - @Override - public String getUsername() { - return subject; - } - - @Override - public Date getIssuedAt() { - return new Date(); - } - - @Override - public Date getExpiresAt() { - var calendar = Calendar.getInstance(); - calendar.set(Calendar.YEAR, 3000); - return calendar.getTime(); - } - - @Override - public List getAudience() { - return List.of(); - } - - @Override - public String getToken() { - return ""; - } - - @Override - public String getIssuer() { - return ""; - } - - @Override - public String getType() { - return ""; - } - - @Override - public String getClientId() { - return ""; - } - - @Override - public String getClaimString(String name) { - return ""; - } - - @Override - public List getClaimList(String name) { - return List.of(); - } - } - - public static class MockEntityRelationService implements EntityRelationService { - private final Map projectToOrganization; - - public MockEntityRelationService() { - this(Map.of()); - } - - public MockEntityRelationService(Map projectToOrganization) { - this.projectToOrganization = projectToOrganization; - } - - @Override - public boolean organizationContainsProject(@NotNull String organization, - @NotNull String project) { - return findOrganizationOfProject(project).equals(organization); - } - - @NotNull - @Override - public String findOrganizationOfProject(@NotNull String project) { - return projectToOrganization.getOrDefault(project, "main"); - } - } - - @BeforeEach - public void setUp() { - this.oracle = new AuthorizationOracle(new MockEntityRelationService()); - this.token = new MockToken(); - } - - @Test - void notHasPermissionWithoutScope() { - assertFalse(oracle.hasScope(token, MEASUREMENT_CREATE)); - } - - @Test - void notHasPermissionWithoutAuthority() { - token.scopes.add("MEASUREMENT_CREATE"); - assertFalse(oracle.hasScope(token, MEASUREMENT_CREATE)); - } - - @Test - void hasPermissionAsAdmin() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(SYS_ADMIN)); - assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); - } - - @Test - void hasPermissionAsUser() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(PARTICIPANT, "some")); - assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); - } - - @Test - void hasPermissionAsClient() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.grantType = CLIENT_CREDENTIALS; - assertTrue(oracle.hasScope(token, MEASUREMENT_CREATE)); - } - - @Test - void notHasPermissionOnProjectWithoutScope() { - MockToken token = new MockToken(); - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); - } - - @Test - void notHasPermissioOnProjectnWithoutAuthority() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); - } - - @Test - void hasPermissionOnProjectAsAdmin() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(SYS_ADMIN)); - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); - } - - @Test - void hasPermissionOnProjectAsUser() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(PARTICIPANT, "project")); - token.subject = "subject"; - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e - .project("project").subject("subject"))); - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("otherProject"))); - } - - @Test - void hasPermissionOnProjectAsClient() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.grantType = CLIENT_CREDENTIALS; - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, e -> e.project("project"))); - } - - - @Test - void notHasPermissionOnSubjectWithoutScope() { - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("subject"))); - } - - @Test - void notHasPermissioOnSubjectnWithoutAuthority() { - MockToken token = new MockToken(); - token.scopes.add(MEASUREMENT_CREATE.scope()); - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("subject"))); - } - - @Test - void hasPermissionOnSubjectAsAdmin() { - MockToken token = new MockToken(); - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(SYS_ADMIN)); - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("subject"))); - } - - @Test - void hasPermissionOnSubjectAsUser() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.roles.add(new AuthorityReference(PARTICIPANT, "project")); - token.subject = "subject"; - - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("subject"))); - assertFalse(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("otherSubject"))); - } - - @Test - void hasPermissionOnSubjectAsClient() { - token.scopes.add(MEASUREMENT_CREATE.scope()); - token.grantType = CLIENT_CREDENTIALS; - assertTrue(oracle.hasPermission(token, MEASUREMENT_CREATE, - e -> e.project("project").subject("subject"))); - } -} diff --git a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt new file mode 100644 index 000000000..a61871aa0 --- /dev/null +++ b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt @@ -0,0 +1,227 @@ +package org.radarbase.auth.token + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.radarbase.auth.authorization.* +import org.radarbase.auth.token.RadarToken.Companion.CLIENT_CREDENTIALS +import java.time.Instant + +class AbstractRadarTokenTest { + private lateinit var oracle: AuthorizationOracle + private lateinit var token: DataRadarToken + + private fun createMockToken() = DataRadarToken( + roles = emptySet(), + scopes = emptySet(), + grantType = "refresh_token", + expiresAt = Instant.MAX, + ) + + class MockEntityRelationService @JvmOverloads constructor( + private val projectToOrganization: Map = mapOf() + ) : EntityRelationService { + override fun findOrganizationOfProject(project: String): String { + return projectToOrganization[project] ?: "main" + } + } + + @BeforeEach + fun setUp() { + oracle = MPAuthorizationOracle(MockEntityRelationService()) + token = createMockToken() + } + + @Test + fun notHasPermissionWithoutScope() { + assertFalse(oracle.hasScope(token, Permission.MEASUREMENT_CREATE)) + } + + @Test + fun notHasPermissionWithoutAuthority() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + ) + assertFalse(oracle.hasScope(token, Permission.MEASUREMENT_CREATE)) + } + + @Test + fun hasPermissionAsAdmin() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)) + ) + assertTrue(oracle.hasScope(token, Permission.MEASUREMENT_CREATE)) + } + + @Test + fun hasPermissionAsUser() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.PARTICIPANT, "some")), + ) + assertTrue(oracle.hasScope(token, Permission.MEASUREMENT_CREATE)) + } + + @Test + fun hasPermissionAsClient() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + grantType = CLIENT_CREDENTIALS + ) + assertTrue(oracle.hasScope(token, Permission.MEASUREMENT_CREATE)) + } + + @Test + fun notHasPermissionOnProjectWithoutScope() { + assertFalse( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project") + ) + ) + } + + @Test + fun notHasPermissioOnProjectnWithoutAuthority() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()) + ) + assertFalse( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project") + ) + ) + } + + @Test + fun hasPermissionOnProjectAsAdmin() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)), + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project") + ) + ) + } + + @Test + fun hasPermissionOnProjectAsUser() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.PARTICIPANT, "project")), + subject = "subject", + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject") + ) + ) + assertFalse( + oracle.hasPermission( + token, Permission.MEASUREMENT_CREATE, EntityDetails(project = "project"), + ) + ) + } + + @Test + fun hasPermissionOnProjectAsClient() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + grantType = CLIENT_CREDENTIALS, + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project") + ) + ) + } + + @Test + fun notHasPermissionOnSubjectWithoutScope() { + assertFalse( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject") + ) + ) + } + + @Test + fun notHasPermissioOnSubjectnWithoutAuthority() { + token = token.copy(scopes = setOf(Permission.MEASUREMENT_CREATE.scope())) + assertFalse( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject") + ), + ) + } + + @Test + fun hasPermissionOnSubjectAsAdmin() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)), + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject") + ) + ) + } + + @Test + fun hasPermissionOnSubjectAsUser() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + roles = setOf(AuthorityReference(RoleAuthority.PARTICIPANT, "project")), + subject = "subject", + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject") + ) + ) + assertFalse( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "otherSubject") + ) + ) + } + + @Test + fun hasPermissionOnSubjectAsClient() { + token = token.copy( + scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), + grantType = CLIENT_CREDENTIALS + ) + assertTrue( + oracle.hasPermission( + token, + Permission.MEASUREMENT_CREATE, + EntityDetails(project = "project", subject = "subject"), + ) + ) + } +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt b/radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt new file mode 100644 index 000000000..58e996aae --- /dev/null +++ b/radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt @@ -0,0 +1,80 @@ +package org.radarbase.auth.util + +import kotlinx.coroutines.* +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.greaterThan +import org.hamcrest.Matchers.lessThan +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +@OptIn(ExperimentalTime::class) +class ExtensionsKtTest { + companion object { + @BeforeAll + @JvmStatic + fun setUpClass() { + runBlocking { + println("warmed up coroutines") + } + } + } + + @Test + fun testConsumeFirst() = runBlocking { + val inBlockingTime = measureTime { + val first = consumeFirst { emit -> + listOf( + async(Dispatchers.Default) { + delay(200.milliseconds) + emit("a") + fail("Should be cancelled") + }, + async(Dispatchers.Default) { + delay(50.milliseconds) + emit("b") + }, + ).awaitAll() + } + assertEquals("b", first) + } + assertThat(inBlockingTime, greaterThan(50.milliseconds)) + assertThat(inBlockingTime, lessThan(200.milliseconds)) + } + + @Test + fun testForkJoin() = runBlocking { + val inBlockingTime = measureTime { + val result = listOf(100.milliseconds, 50.milliseconds) + .forkJoin { + delay(it) + it + } + assertEquals(listOf(100.milliseconds, 50.milliseconds), result) + } + assertThat(inBlockingTime, greaterThan(100.milliseconds)) + } + + + @Test + fun testForkJoinFirst() = runBlocking { + val inBlockingTime = measureTime { + val result: Duration? = consumeFirst { emit -> + listOf(200.milliseconds, 50.milliseconds) + .forkJoin { + delay(it) + emit(it) + } + emit(null) + } + assertEquals(50.milliseconds, result) + } + assertThat(inBlockingTime, lessThan(200.milliseconds)) + assertThat(inBlockingTime, greaterThan(50.milliseconds)) + } +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java b/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java deleted file mode 100644 index f10963395..000000000 --- a/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.java +++ /dev/null @@ -1,272 +0,0 @@ -package org.radarbase.auth.util; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.auth0.jwt.interfaces.RSAKeyProvider; -import org.radarbase.auth.authorization.Permission; - -import java.io.IOException; -import java.io.InputStream; -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.security.cert.Certificate; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.time.Instant; -import java.util.Base64; -import java.util.Date; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Sets up a keypair for signing the tokens, initialize all kinds of different tokens for tests. - */ -public final class TokenTestUtils { - public static final String PUBLIC_KEY = "/oauth/token_key"; - public static final String PUBLIC_KEY_BODY; - public static final String VALID_RSA_TOKEN; - public static final String INCORRECT_AUDIENCE_TOKEN; - public static final String EXPIRED_TOKEN; - public static final String INCORRECT_ALGORITHM_TOKEN; - public static final DecodedJWT SCOPE_TOKEN; - public static final DecodedJWT ORGANIZATION_ADMIN_TOKEN; - public static final DecodedJWT PROJECT_ADMIN_TOKEN; - public static final DecodedJWT SUPER_USER_TOKEN; - public static final DecodedJWT MULTIPLE_ROLES_IN_PROJECT_TOKEN; - - public static final String[] AUTHORITIES = {"ROLE_SYS_ADMIN", "ROLE_USER"}; - public static final String[] ALL_SCOPES = Stream.of(Permission.values()) - .map(Permission::scope).collect(Collectors.toList()).toArray(new String[0]); - public static final String[] ROLES = {"PROJECT1:ROLE_PROJECT_ADMIN", - "PROJECT2:ROLE_PARTICIPANT"}; - public static final String[] SOURCES = {}; - public static final String CLIENT = "unit_test"; - public static final String USER = "admin"; - public static final String ISS = "RADAR"; - public static final String JTI = "some-jwt-id"; - public static final String PUBLIC_KEY_STRING; - - public static final String APPLICATION_JSON = "application/json"; - public static final int WIREMOCK_PORT = 8089; - - private TokenTestUtils() { - // utility class - } - - static { - RSAKeyProvider provider; - try { - provider = loadKeys(); - } catch (GeneralSecurityException | IOException e) { - throw new IllegalStateException("Failed to load keys for test", e); - } - RSAPublicKey publicKey = provider.getPublicKeyById("selfsigned"); - PUBLIC_KEY_STRING = new String(Base64.getEncoder().encode( - provider.getPublicKeyById("selfsigned").getEncoded())); - Algorithm algorithm = Algorithm.RSA256(publicKey, provider.getPrivateKey()); - - PUBLIC_KEY_BODY = "{\n \"keys\" : [ {\n \"alg\" : \"" + algorithm.getName() - + "\",\n \"kty\" : \"RSA\",\n" - + " \"value\" : \"-----BEGIN PUBLIC KEY-----\\n" + PUBLIC_KEY_STRING - + "\\n-----END PUBLIC KEY-----\"\n} ]\n}"; - - Instant exp = Instant.now().plusSeconds(30 * 60); - Instant iat = Instant.now(); - - VALID_RSA_TOKEN = initValidToken(algorithm, exp, iat); - SUPER_USER_TOKEN = JWT.decode(VALID_RSA_TOKEN); - PROJECT_ADMIN_TOKEN = initProjectAdminToken(algorithm, exp, iat); - ORGANIZATION_ADMIN_TOKEN = initOrgananizationAdminToken(algorithm, exp, iat); - MULTIPLE_ROLES_IN_PROJECT_TOKEN = initMultipleRolesToken(algorithm, exp, iat); - INCORRECT_AUDIENCE_TOKEN = initIncorrectAudienceToken(algorithm, exp, iat); - SCOPE_TOKEN = initTokenWithScopes(algorithm, exp, iat); - INCORRECT_ALGORITHM_TOKEN = initIncorrectAlgorithmToken(exp, iat); - - Instant past = Instant.now().minusSeconds(1); - Instant iatpast = Instant.now().minusSeconds(30 * 60 + 1); - EXPIRED_TOKEN = initExpiredToken(algorithm, past, iatpast); - } - - private static RSAKeyProvider loadKeys() throws GeneralSecurityException, IOException { - KeyStore ks = KeyStore.getInstance("PKCS12"); - try (InputStream keyStream = Thread.currentThread().getContextClassLoader() - .getResourceAsStream("keystore.p12")) { - ks.load(keyStream, "radarbase".toCharArray()); - } - RSAPrivateKey privateKey = (RSAPrivateKey) ks.getKey("selfsigned", - "radarbase".toCharArray()); - Certificate cert = ks.getCertificate("selfsigned"); - return new RSAKeyProvider() { - @Override - public RSAPublicKey getPublicKeyById(String keyId) { - return (RSAPublicKey) cert.getPublicKey(); - } - - @Override - public RSAPrivateKey getPrivateKey() { - return privateKey; - } - - @Override - public String getPrivateKeyId() { - return null; - } - }; - } - - private static String initExpiredToken(Algorithm algorithm, Instant past, Instant iatpast) { - return JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iatpast)) - .withExpiresAt(Date.from(past)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", AUTHORITIES) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", SOURCES) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - } - - private static String initIncorrectAlgorithmToken(Instant exp, Instant iat) { - Algorithm psk = Algorithm.HMAC256("super-secret-stuff"); - // token signed with a pre-shared key - return JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", new String[] {"ROLE_PROJECT_ADMIN"}) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", new String[] {}) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(psk); - } - - private static String initIncorrectAudienceToken(Algorithm algorithm, Instant exp, - Instant iat) { - return JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience("SOME_AUDIENCE") - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", new String[] {"ROLE_PROJECT_ADMIN"}) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", new String[] {}) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - } - - private static DecodedJWT initMultipleRolesToken(Algorithm algorithm, Instant exp, - Instant iat) { - String multipleRolesInProjectToken = JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", new String[] {"ROLE_PROJECT_ADMIN"}) - .withArrayClaim("roles", new String[] {"PROJECT2:ROLE_PROJECT_ADMIN", - "PROJECT2:ROLE_PARTICIPANT"}) - .withArrayClaim("sources", new String[] {"source-1"}) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - - return JWT.decode(multipleRolesInProjectToken); - } - - private static DecodedJWT initOrgananizationAdminToken(Algorithm algorithm, Instant exp, - Instant iat) { - String projectAdminToken = JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", new String[] {"ROLE_ORGANIZATION_ADMIN"}) - .withArrayClaim("roles", new String[] {"main:ROLE_ORGANIZATION_ADMIN"}) - .withArrayClaim("sources", new String[] {}) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - - return JWT.decode(projectAdminToken); - } - - private static DecodedJWT initProjectAdminToken(Algorithm algorithm, Instant exp, Instant iat) { - String projectAdminToken = JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", new String[] {"ROLE_PROJECT_ADMIN", - "ROLE_PARTICIPANT"}) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", new String[] {}) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - - return JWT.decode(projectAdminToken); - } - - private static String initValidToken(Algorithm algorithm, Instant exp, Instant iat) { - return JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject(USER) - .withArrayClaim("scope", ALL_SCOPES) - .withArrayClaim("authorities", AUTHORITIES) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", SOURCES) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm); - } - - private static DecodedJWT initTokenWithScopes(Algorithm algorithm, Instant exp, Instant iat) { - String token = JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(iat)) - .withExpiresAt(Date.from(exp)) - .withAudience(CLIENT) - .withSubject("i'm a trusted oauth client") - .withArrayClaim("scope", new String[] {"PROJECT.READ", "SUBJECT.CREATE", - "SUBJECT.READ", "MEASUREMENT.CREATE"}) - .withClaim("client_id", "i'm a trusted oauth client") - .withClaim("jti", JTI) - .withClaim("grant_type", "client_credentials") - .sign(algorithm); - return JWT.decode(token); - } -} diff --git a/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.kt b/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.kt new file mode 100644 index 000000000..e509eab39 --- /dev/null +++ b/radar-auth/src/test/java/org/radarbase/auth/util/TokenTestUtils.kt @@ -0,0 +1,284 @@ +package org.radarbase.auth.util + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import com.auth0.jwt.interfaces.RSAKeyProvider +import org.radarbase.auth.authorization.Permission +import org.slf4j.LoggerFactory +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Duration +import java.time.Instant +import java.util.* + +/** + * Sets up a keypair for signing the tokens, initialize all kinds of different tokens for tests. + */ +object TokenTestUtils { + private val logger = LoggerFactory.getLogger(TokenTestUtils::class.java) + + const val PUBLIC_KEY_PATH = "/oauth/token_key" + val PUBLIC_KEY_STRING: String + @JvmField val PUBLIC_KEY_BODY: String + @JvmField val VALID_RSA_TOKEN: String + @JvmField val INCORRECT_AUDIENCE_TOKEN: String + @JvmField val EXPIRED_TOKEN: String + @JvmField val INCORRECT_ALGORITHM_TOKEN: String + @JvmField val SCOPE_TOKEN: DecodedJWT + @JvmField val ORGANIZATION_ADMIN_TOKEN: DecodedJWT + @JvmField val PROJECT_ADMIN_TOKEN: DecodedJWT + @JvmField val SUPER_USER_TOKEN: DecodedJWT + @JvmField val MULTIPLE_ROLES_IN_PROJECT_TOKEN: DecodedJWT + val AUTHORITIES = arrayOf("ROLE_SYS_ADMIN", "ROLE_USER") + val ALL_SCOPES = Permission.scopes() + val ROLES = arrayOf( + "PROJECT1:ROLE_PROJECT_ADMIN", + "PROJECT2:ROLE_PARTICIPANT" + ) + val SOURCES = arrayOf() + const val CLIENT = "unit_test" + const val USER = "admin" + const val ISS = "RADAR" + const val JTI = "some-jwt-id" + const val APPLICATION_JSON = "application/json" + const val WIREMOCK_PORT = 8089 + + init { + val provider: RSAKeyProvider = try { + loadKeys() + } catch (e: GeneralSecurityException) { + throw IllegalStateException("Failed to load keys for test", e) + } catch (e: IOException) { + throw IllegalStateException("Failed to load keys for test", e) + } + val publicKey = provider.getPublicKeyById("selfsigned") + PUBLIC_KEY_STRING = String( + Base64.getEncoder().encode( + provider.getPublicKeyById("selfsigned").encoded + ) + ) + val algorithm = Algorithm.RSA256(publicKey, provider.privateKey) + PUBLIC_KEY_BODY = """ + { + "keys" : [ { + "alg" : "${algorithm.name}", + "kty" : "RSA", + "value" : "-----BEGIN PUBLIC KEY-----\n$PUBLIC_KEY_STRING\n-----END PUBLIC KEY-----" + } ] + } + """.trimIndent() + logger.info("Key body {}", PUBLIC_KEY_BODY) + val exp = Instant.now() + Duration.ofMinutes(30) + val iat = Instant.now() + VALID_RSA_TOKEN = initValidToken(algorithm, exp, iat) + SUPER_USER_TOKEN = JWT.decode(VALID_RSA_TOKEN) + PROJECT_ADMIN_TOKEN = initProjectAdminToken(algorithm, exp, iat) + ORGANIZATION_ADMIN_TOKEN = initOrgananizationAdminToken(algorithm, exp, iat) + MULTIPLE_ROLES_IN_PROJECT_TOKEN = initMultipleRolesToken(algorithm, exp, iat) + INCORRECT_AUDIENCE_TOKEN = initIncorrectAudienceToken(algorithm, exp, iat) + SCOPE_TOKEN = initTokenWithScopes(algorithm, exp, iat) + INCORRECT_ALGORITHM_TOKEN = initIncorrectAlgorithmToken(exp, iat) + val past = Instant.now().minusSeconds(1) + val iatpast = Instant.now().minusSeconds((30 * 60 + 1).toLong()) + EXPIRED_TOKEN = initExpiredToken(algorithm, past, iatpast) + } + + @Throws(GeneralSecurityException::class, IOException::class) + private fun loadKeys(): RSAKeyProvider { + val ks = KeyStore.getInstance("PKCS12") + Thread.currentThread().contextClassLoader + .getResourceAsStream("keystore.p12") + .use { keyStream -> ks.load(keyStream, "radarbase".toCharArray()) } + val privateKey = ks.getKey( + "selfsigned", + "radarbase".toCharArray() + ) as RSAPrivateKey + val cert = ks.getCertificate("selfsigned") + return object : RSAKeyProvider { + override fun getPublicKeyById(keyId: String): RSAPublicKey = + cert.publicKey as RSAPublicKey + + override fun getPrivateKey(): RSAPrivateKey = privateKey + + override fun getPrivateKeyId(): String = "1" + } + } + + private fun initExpiredToken(algorithm: Algorithm, past: Instant, iatpast: Instant): String { + return JWT.create() + .withIssuer(ISS) + .withIssuedAt(iatpast) + .withExpiresAt(past) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", AUTHORITIES) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", SOURCES) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + } + + private fun initIncorrectAlgorithmToken(exp: Instant, iat: Instant): String { + val psk = Algorithm.HMAC256("super-secret-stuff") + // token signed with a pre-shared key + return JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", arrayOf("ROLE_PROJECT_ADMIN")) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", arrayOf()) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(psk) + } + + private fun initIncorrectAudienceToken( + algorithm: Algorithm, exp: Instant, + iat: Instant + ): String { + return JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience("SOME_AUDIENCE") + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", arrayOf("ROLE_PROJECT_ADMIN")) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", arrayOf()) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + } + + private fun initMultipleRolesToken( + algorithm: Algorithm, exp: Instant, + iat: Instant + ): DecodedJWT { + val multipleRolesInProjectToken = JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", arrayOf("ROLE_PROJECT_ADMIN")) + .withArrayClaim( + "roles", arrayOf( + "PROJECT2:ROLE_PROJECT_ADMIN", + "PROJECT2:ROLE_PARTICIPANT" + ) + ) + .withArrayClaim("sources", arrayOf("source-1")) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + return JWT.decode(multipleRolesInProjectToken) + } + + private fun initOrgananizationAdminToken( + algorithm: Algorithm, exp: Instant, + iat: Instant + ): DecodedJWT { + val projectAdminToken = JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", arrayOf("ROLE_ORGANIZATION_ADMIN")) + .withArrayClaim("roles", arrayOf("main:ROLE_ORGANIZATION_ADMIN")) + .withArrayClaim("sources", arrayOf()) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + return JWT.decode(projectAdminToken) + } + + private fun initProjectAdminToken( + algorithm: Algorithm, + exp: Instant, + iat: Instant + ): DecodedJWT { + val projectAdminToken = JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim( + "authorities", arrayOf( + "ROLE_PROJECT_ADMIN", + "ROLE_PARTICIPANT" + ) + ) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", arrayOf()) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + return JWT.decode(projectAdminToken) + } + + private fun initValidToken(algorithm: Algorithm, exp: Instant, iat: Instant): String { + return JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject(USER) + .withArrayClaim("scope", ALL_SCOPES) + .withArrayClaim("authorities", AUTHORITIES) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", SOURCES) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) + } + + private fun initTokenWithScopes(algorithm: Algorithm, exp: Instant, iat: Instant): DecodedJWT { + val token = JWT.create() + .withIssuer(ISS) + .withIssuedAt(iat) + .withExpiresAt(exp) + .withAudience(CLIENT) + .withSubject("i'm a trusted oauth client") + .withArrayClaim( + "scope", arrayOf( + "PROJECT.READ", "SUBJECT.CREATE", + "SUBJECT.READ", "MEASUREMENT.CREATE" + ) + ) + .withClaim("client_id", "i'm a trusted oauth client") + .withClaim("jti", JTI) + .withClaim("grant_type", "client_credentials") + .sign(algorithm) + return JWT.decode(token) + } +} diff --git a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java index 5cbc492c8..f4afc6a99 100644 --- a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java +++ b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java @@ -1,11 +1,11 @@ package org.radarbase.management.security; import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.jwt.JwtRadarToken; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.repository.UserRepository; +import org.radarbase.management.service.AuthService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -29,6 +29,10 @@ import java.util.TreeSet; import java.util.stream.Collectors; +import static org.radarbase.auth.jwt.JwtTokenVerifier.GRANT_TYPE_CLAIM; +import static org.radarbase.auth.jwt.JwtTokenVerifier.ROLES_CLAIM; +import static org.radarbase.auth.jwt.JwtTokenVerifier.SOURCES_CLAIM; + public class ClaimsTokenEnhancer implements TokenEnhancer, InitializingBean { private static final Logger logger = LoggerFactory.getLogger(ClaimsTokenEnhancer.class); @@ -41,6 +45,9 @@ public class ClaimsTokenEnhancer implements TokenEnhancer, InitializingBean { @Autowired private AuditEventRepository auditEventRepository; + @Autowired + private AuthService authService; + @Value("${spring.application.name}") private String appName; @@ -74,7 +81,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, }; }) .collect(Collectors.toList()); - additionalInfo.put(JwtRadarToken.ROLES_CLAIM, roles); + additionalInfo.put(ROLES_CLAIM, roles); // Do not grant scopes that cannot be given to a user. Set currentScopes = accessToken.getScope(); @@ -83,7 +90,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, Permission permission = Permission.ofScope(scope); return user.getRoles().stream() .map(Role::getRole) - .anyMatch(r -> r.mayBeGranted(permission)); + .anyMatch(r -> authService.mayBeGranted(r, permission)); }) .collect(Collectors.toCollection(TreeSet::new)); @@ -97,12 +104,12 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, List sourceIds = assignedSources.stream() .map(s -> s.getSourceId().toString()) .collect(Collectors.toList()); - additionalInfo.put(JwtRadarToken.SOURCES_CLAIM, sourceIds); + additionalInfo.put(SOURCES_CLAIM, sourceIds); } // add iat and iss optional JWT claims additionalInfo.put("iat", Instant.now().getEpochSecond()); additionalInfo.put("iss", appName); - additionalInfo.put(JwtRadarToken.GRANT_TYPE_CLAIM, + additionalInfo.put(GRANT_TYPE_CLAIM, authentication.getOAuth2Request().getGrantType()); ((DefaultOAuth2AccessToken) accessToken) .setAdditionalInformation(additionalInfo); diff --git a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java index c31c2b133..95563b9a2 100644 --- a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java @@ -2,7 +2,7 @@ import org.radarbase.auth.authentication.TokenValidator; import org.radarbase.auth.exception.TokenValidationException; -import org.radarbase.auth.token.AuthorityReference; +import org.radarbase.auth.authorization.AuthorityReference; import org.radarbase.auth.token.RadarToken; import org.radarbase.management.repository.UserRepository; import org.slf4j.Logger; @@ -11,7 +11,6 @@ import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -35,6 +34,7 @@ */ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + public static final String AUTHORIZATION_BEARER_HEADER = "Bearer"; private final TokenValidator validator; private final AuthenticationManager authenticationManager; public static final String TOKEN_ATTRIBUTE = "jwt"; @@ -96,25 +96,24 @@ protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, } HttpSession session = httpRequest.getSession(false); - SessionRadarToken token = null; + RadarToken token = null; if (session != null) { - token = SessionRadarToken.from((RadarToken) session.getAttribute(TOKEN_ATTRIBUTE)); + token = (RadarToken) session.getAttribute(TOKEN_ATTRIBUTE); } if (token == null) { try { - token = SessionRadarToken.from(validator.authenticateBlocking(getToken(httpRequest, - httpResponse))); + token = validator.validateBlocking(getToken(httpRequest, httpResponse)); } catch (TokenValidationException ex) { if (isOptional) { logger.debug("Skipping optional token: {}", ex.getMessage()); } else { logger.error("Failed to validate token: {}", ex.getMessage()); httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader( - HttpHeaders.WWW_AUTHENTICATE, OAuth2AccessToken.BEARER_TYPE); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, + AUTHORIZATION_BEARER_HEADER); httpResponse.getOutputStream().print( "{\"error\": \"" + "Unauthorized" + ",\n" - + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + "\",\n" + "\"message\": \"" + ex.getMessage() + "\",\n" + "\"path\": \"" + httpRequest.getRequestURI() + "\n" @@ -137,11 +136,11 @@ protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, }; }) .collect(Collectors.toSet()); - token = token.withRoles(roles); + token = token.copyWithRoles(roles); } else { session.removeAttribute(TOKEN_ATTRIBUTE); httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, OAuth2AccessToken.BEARER_TYPE); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); httpResponse.getOutputStream().print( "{\"error\": \"" + "Unauthorized" + ",\n" + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + ",\n" @@ -182,14 +181,14 @@ private String getToken(ServletRequest request, ServletResponse response) { // Check if the HTTP Authorization header is present and formatted correctly if (authorizationHeader == null || !authorizationHeader - .startsWith(OAuth2AccessToken.BEARER_TYPE)) { + .startsWith(AUTHORIZATION_BEARER_HEADER)) { res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - res.setHeader(HttpHeaders.WWW_AUTHENTICATE, OAuth2AccessToken.BEARER_TYPE); - throw new TokenValidationException("No " + OAuth2AccessToken.BEARER_TYPE + " token " - + "present in the request to " + req.getServletPath()); + res.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); + throw new TokenValidationException("No " + AUTHORIZATION_BEARER_HEADER + + " Authorization token present in the request to " + req.getServletPath()); } // Extract the token from the HTTP Authorization header - return authorizationHeader.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim(); + return authorizationHeader.substring(AUTHORIZATION_BEARER_HEADER.length()).trim(); } } diff --git a/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt b/src/main/java/org/radarbase/management/security/NotAuthorizedException.kt similarity index 89% rename from radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt rename to src/main/java/org/radarbase/management/security/NotAuthorizedException.kt index b10396ccf..b772712e2 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/exception/NotAuthorizedException.kt +++ b/src/main/java/org/radarbase/management/security/NotAuthorizedException.kt @@ -1,4 +1,4 @@ -package org.radarbase.auth.exception +package org.radarbase.management.security import java.security.GeneralSecurityException diff --git a/src/main/java/org/radarbase/management/security/RadarAuthentication.java b/src/main/java/org/radarbase/management/security/RadarAuthentication.java index dd5a1a54a..b743b52b5 100644 --- a/src/main/java/org/radarbase/management/security/RadarAuthentication.java +++ b/src/main/java/org/radarbase/management/security/RadarAuthentication.java @@ -9,7 +9,7 @@ package org.radarbase.management.security; -import org.radarbase.auth.token.AuthorityReference; +import org.radarbase.auth.authorization.AuthorityReference; import org.radarbase.auth.token.RadarToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; diff --git a/src/main/java/org/radarbase/management/security/SessionRadarToken.java b/src/main/java/org/radarbase/management/security/SessionRadarToken.java deleted file mode 100644 index 2f8ca352a..000000000 --- a/src/main/java/org/radarbase/management/security/SessionRadarToken.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (c) 2021. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.management.security; - -import org.jetbrains.annotations.NotNull; -import org.radarbase.auth.token.AbstractRadarToken; -import org.radarbase.auth.token.AuthorityReference; -import org.radarbase.auth.token.RadarToken; - -import java.io.Serializable; -import java.util.Date; -import java.util.List; -import java.util.Set; - -public class SessionRadarToken extends AbstractRadarToken implements Serializable { - private final Set roles; - private final String subject; - private final String token; - private final Set scopes; - private final List audience; - private final List sources; - private final String grantType; - private final Date issuedAt; - private final Date expiresAt; - private final String issuer; - private final String clientId; - private final String type; - private final String username; - - /** Instantiate a serializable session token by copying an existing RadarToken. */ - public SessionRadarToken(RadarToken token) { - this(token, token.getRoles()); - } - - /** Instantiate a serializable session token by copying an existing RadarToken. */ - private SessionRadarToken(RadarToken token, Set roles) { - this.roles = Set.copyOf(roles); - this.subject = token.getSubject(); - this.token = token.getToken(); - this.scopes = Set.copyOf(token.getScopes()); - this.audience = List.copyOf(token.getAudience()); - this.sources = List.copyOf(token.getSources()); - this.grantType = token.getGrantType(); - this.issuedAt = token.getIssuedAt(); - this.expiresAt = token.getExpiresAt(); - this.issuer = token.getIssuer(); - this.clientId = token.getClientId(); - this.type = token.getType(); - this.username = token.getUsername(); - } - - @NotNull - @Override - public Set getRoles() { - return roles; - } - - @NotNull - @Override - public Set getScopes() { - return scopes; - } - - @NotNull - @Override - public List getSources() { - return sources; - } - - @NotNull - @Override - public String getGrantType() { - return grantType; - } - - @NotNull - @Override - public String getSubject() { - return subject; - } - - @NotNull - @Override - public Date getIssuedAt() { - return issuedAt; - } - - @NotNull - @Override - public Date getExpiresAt() { - return expiresAt; - } - - @NotNull - @Override - public List getAudience() { - return audience; - } - - @NotNull - @Override - public String getToken() { - return token; - } - - @NotNull - @Override - public String getIssuer() { - return issuer; - } - - @NotNull - @Override - public String getType() { - return type; - } - - @NotNull - @Override - public String getClientId() { - return clientId; - } - - @Override - public String getClaimString(@NotNull String name) { - return ""; - } - - @NotNull - @Override - public List getClaimList(@NotNull String name) { - return List.of(); - } - - @NotNull - @Override - public String getUsername() { - return username; - } - - public SessionRadarToken withRoles(Set roles) { - return new SessionRadarToken(this, roles); - } - - /** - * Create a new token. - * @return null if provided null, a session radar token otherwise. - */ - public static SessionRadarToken from(RadarToken token) { - if (token == null) { - return null; - } else { - return new SessionRadarToken(token); - } - } -} diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index c636bbe1f..bf103d41f 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -1,13 +1,12 @@ package org.radarbase.management.service import org.radarbase.auth.authorization.* -import org.radarbase.auth.exception.NotAuthorizedException import org.radarbase.auth.token.RadarToken +import org.radarbase.management.security.NotAuthorizedException import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.util.* import java.util.function.Consumer -import kotlin.jvm.Throws @Service open class AuthService { @@ -17,7 +16,7 @@ open class AuthService { @Autowired(required = false) private var token: RadarToken? = null - private val oracle: AuthorizationOracle = AuthorizationOracle( + private val oracle: AuthorizationOracle = MPAuthorizationOracle( object : EntityRelationService { override fun findOrganizationOfProject(project: String): String { return projectService.findOneByName(project).organization.name @@ -25,12 +24,28 @@ open class AuthService { } ) + /** + * Check whether given [token] would have the [permission] scope in any of its roles. This doesn't + * check whether [token] has access to a specific entity or global access. + * @throws NotAuthorizedException if identity does not have scope + */ @Throws(NotAuthorizedException::class) open fun checkScope(permission: Permission) { val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") - oracle.checkScope(token, permission) + + if (!oracle.hasScope(token, permission)) { + throw NotAuthorizedException( + "User ${token.username} with client ${token.clientId} does not have permission $permission" + ) + } } + /** + * Check whether [token] has permission [permission], regarding given entity from [builder]. + * The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws NotAuthorizedException if identity does not have permission + */ @JvmOverloads @Throws(NotAuthorizedException::class) open fun checkPermission( @@ -39,47 +54,22 @@ open class AuthService { scope: Permission.Entity = permission.entity, ) { val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") - oracle.checkPermission(token, permission, if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global, scope) - } - - @JvmOverloads - open fun hasPermission( - permission: Permission, - builder: Consumer? = null, - scope: Permission.Entity = permission.entity, - ): Boolean { - val token = token ?: return false - return oracle.hasPermission( - token, - permission, - if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global, - scope - ) - } - - @JvmOverloads - open fun hasPermission( - permission: Permission, - entityDetails: EntityDetails, - scope: Permission.Entity = permission.entity, - ): Boolean { - val token = token ?: return false - return oracle.hasPermission(token, permission, entityDetails, scope) - } - @JvmOverloads - @Throws(NotAuthorizedException::class) - open fun checkPermission( - permission: Permission, - entityDetails: EntityDetails, - scope: Permission.Entity = permission.entity, - ) { - val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") - oracle.checkPermission(token, permission, entityDetails, scope) + val entity = if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global + if (!oracle.hasPermission(token, permission, entity, scope)) { + throw NotAuthorizedException( + "User ${token.username} with client ${token.clientId} does not have permission $permission to scope " + + "$token of $entity" + ) + } } open fun referentsByScope(permission: Permission): AuthorityReferenceSet { val token = token ?: return AuthorityReferenceSet() return oracle.referentsByScope(token, permission) } + + fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { + role.mayBeGranted(permission) + } } diff --git a/src/main/java/org/radarbase/management/service/MetaTokenService.java b/src/main/java/org/radarbase/management/service/MetaTokenService.java index 0c20fcbed..e82a4c338 100644 --- a/src/main/java/org/radarbase/management/service/MetaTokenService.java +++ b/src/main/java/org/radarbase/management/service/MetaTokenService.java @@ -1,11 +1,11 @@ package org.radarbase.management.service; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.MetaToken; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Subject; import org.radarbase.management.repository.MetaTokenRepository; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.dto.ClientPairInfoDTO; import org.radarbase.management.service.dto.TokenDTO; import org.radarbase.management.web.rest.errors.BadRequestException; diff --git a/src/main/java/org/radarbase/management/service/OrganizationService.java b/src/main/java/org/radarbase/management/service/OrganizationService.java index 2c9182528..900eb32fe 100644 --- a/src/main/java/org/radarbase/management/service/OrganizationService.java +++ b/src/main/java/org/radarbase/management/service/OrganizationService.java @@ -73,9 +73,9 @@ public List findAll() { if (referents.getGlobal()) { organizationsOfUser = organizationRepository.findAll(); } else { - Set projectNames = referents.getProjects(); + Set projectNames = referents.getAllProjects(); - Stream organizationsOfProject = referents.hasProjects() + Stream organizationsOfProject = !projectNames.isEmpty() ? organizationRepository.findAllByProjectNames(projectNames).stream() : Stream.of(); @@ -120,9 +120,9 @@ public List findAllProjectsByOrganizationName(String organizationNam if (referents.getGlobal() || referents.hasOrganization(organizationName)) { projectStream = projectRepository.findAllByOrganizationName(organizationName).stream(); - } else if (referents.hasProjects()) { + } else if (referents.hasAnyProjects()) { projectStream = projectRepository.findAllByOrganizationName(organizationName).stream() - .filter(project -> referents.hasProject(project.getProjectName())); + .filter(project -> referents.hasAnyProject(project.getProjectName())); } else { return List.of(); } diff --git a/src/main/java/org/radarbase/management/service/ProjectService.java b/src/main/java/org/radarbase/management/service/ProjectService.java index 766e879a6..ef55bd111 100644 --- a/src/main/java/org/radarbase/management/service/ProjectService.java +++ b/src/main/java/org/radarbase/management/service/ProjectService.java @@ -75,7 +75,7 @@ public Page findAll(Boolean fetchMinimal, Pageable pageable) { projects = projectRepository.findAllWithEagerRelationships(pageable); } else { projects = projectRepository.findAllWithEagerRelationshipsInOrganizationsOrProjects( - pageable, referents.getOrganizations(), referents.getProjects()); + pageable, referents.getOrganizations(), referents.getAllProjects()); } if (!fetchMinimal) { diff --git a/src/main/java/org/radarbase/management/service/SourceService.java b/src/main/java/org/radarbase/management/service/SourceService.java index 409efc79d..988b5f3d8 100644 --- a/src/main/java/org/radarbase/management/service/SourceService.java +++ b/src/main/java/org/radarbase/management/service/SourceService.java @@ -1,11 +1,11 @@ package org.radarbase.management.service; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.Source; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.repository.SourceRepository; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.dto.MinimalSourceDetailsDTO; import org.radarbase.management.service.dto.SourceDTO; import org.radarbase.management.service.mapper.SourceMapper; diff --git a/src/main/java/org/radarbase/management/service/SubjectService.java b/src/main/java/org/radarbase/management/service/SubjectService.java index ca00db862..c4f1de3bd 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.java +++ b/src/main/java/org/radarbase/management/service/SubjectService.java @@ -2,7 +2,6 @@ import org.hibernate.envers.query.AuditEntity; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.Group; @@ -18,6 +17,7 @@ import org.radarbase.management.repository.SourceRepository; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.repository.filters.SubjectSpecification; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.dto.MinimalSourceDetailsDTO; import org.radarbase.management.service.dto.SubjectDTO; import org.radarbase.management.service.dto.UserDTO; diff --git a/src/main/java/org/radarbase/management/service/UserService.java b/src/main/java/org/radarbase/management/service/UserService.java index f6d08c83c..b6991b315 100644 --- a/src/main/java/org/radarbase/management/service/UserService.java +++ b/src/main/java/org/radarbase/management/service/UserService.java @@ -1,13 +1,13 @@ package org.radarbase.management.service; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.radarbase.management.repository.filters.UserFilter; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.security.SecurityUtils; import org.radarbase.management.service.dto.RoleDTO; import org.radarbase.management.service.dto.UserDTO; diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index ec792508e..5c2e70ecd 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -2,11 +2,10 @@ import io.micrometer.core.annotation.Timed; import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.auth.token.RadarToken; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.User; -import org.radarbase.management.security.SessionRadarToken; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.PasswordService; @@ -98,8 +97,7 @@ public ResponseEntity login(HttpSession session) throws NotAuthorizedEx throw new NotAuthorizedException("Cannot login without credentials"); } log.debug("Logging in user to session with principal {}", token.getUsername()); - RadarToken sessionToken = new SessionRadarToken(token); - session.setAttribute(TOKEN_ATTRIBUTE, sessionToken); + session.setAttribute(TOKEN_ATTRIBUTE, token); return getAccount(); } diff --git a/src/main/java/org/radarbase/management/web/rest/AuditResource.java b/src/main/java/org/radarbase/management/web/rest/AuditResource.java index bfb40b77e..7ae12f3c2 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuditResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuditResource.java @@ -1,7 +1,7 @@ package org.radarbase.management.web.rest; import io.swagger.v3.oas.annotations.Parameter; -import org.radarbase.auth.exception.NotAuthorizedException; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuditEventService; import org.radarbase.management.service.AuthService; import org.radarbase.management.web.rest.util.PaginationUtil; diff --git a/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java b/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java index 768baf240..b39d4a56c 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuthorityResource.java @@ -2,7 +2,7 @@ import io.micrometer.core.annotation.Timed; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.exception.NotAuthorizedException; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.dto.AuthorityDTO; import org.slf4j.Logger; diff --git a/src/main/java/org/radarbase/management/web/rest/GroupResource.java b/src/main/java/org/radarbase/management/web/rest/GroupResource.java index d46db4d5a..3bafc1289 100644 --- a/src/main/java/org/radarbase/management/web/rest/GroupResource.java +++ b/src/main/java/org/radarbase/management/web/rest/GroupResource.java @@ -9,8 +9,8 @@ package org.radarbase.management.web.rest; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.GroupService; import org.radarbase.management.service.dto.GroupDTO; diff --git a/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java b/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java index c79cebe4a..39b68d62d 100644 --- a/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java +++ b/src/main/java/org/radarbase/management/web/rest/MetaTokenResource.java @@ -2,10 +2,10 @@ import io.micrometer.core.annotation.Timed; -import org.radarbase.management.security.Constants; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.MetaToken; import org.radarbase.management.domain.Subject; +import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MetaTokenService; import org.radarbase.management.service.dto.ClientPairInfoDTO; diff --git a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java index 571382771..df43e6217 100644 --- a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java @@ -1,15 +1,14 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Subject; import org.radarbase.management.domain.User; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MetaTokenService; import org.radarbase.management.service.OAuthClientService; -import org.radarbase.management.service.OrganizationService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SubjectService; import org.radarbase.management.service.UserService; @@ -77,9 +76,6 @@ public class OAuthClientsResource { @Autowired private UserService userService; - @Autowired - private OrganizationService organizationService; - @Autowired private AuditEventRepository eventRepository; diff --git a/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java b/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java index d60e11939..582e81030 100644 --- a/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OrganizationResource.java @@ -1,8 +1,8 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.OrganizationService; import org.radarbase.management.service.ResourceUriService; diff --git a/src/main/java/org/radarbase/management/web/rest/ProjectResource.java b/src/main/java/org/radarbase/management/web/rest/ProjectResource.java index d8012be8d..c2b6ac1db 100644 --- a/src/main/java/org/radarbase/management/web/rest/ProjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/ProjectResource.java @@ -2,9 +2,9 @@ import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.Parameter; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ProjectService; import org.radarbase.management.service.ResourceUriService; diff --git a/src/main/java/org/radarbase/management/web/rest/RoleResource.java b/src/main/java/org/radarbase/management/web/rest/RoleResource.java index 2426231d6..0593e7d80 100644 --- a/src/main/java/org/radarbase/management/web/rest/RoleResource.java +++ b/src/main/java/org/radarbase/management/web/rest/RoleResource.java @@ -2,8 +2,8 @@ import io.micrometer.core.annotation.Timed; import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.RoleService; diff --git a/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java b/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java index 72701f7d3..f3709979e 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceDataResource.java @@ -1,8 +1,8 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceDataService; diff --git a/src/main/java/org/radarbase/management/web/rest/SourceResource.java b/src/main/java/org/radarbase/management/web/rest/SourceResource.java index 70db10c0d..9841f7cd1 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceResource.java @@ -1,10 +1,10 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.SourceRepository; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceService; diff --git a/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java b/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java index 6e64b54f7..eb0bce7ee 100644 --- a/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SourceTypeResource.java @@ -1,10 +1,10 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.SourceTypeRepository; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; import org.radarbase.management.service.SourceTypeService; diff --git a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java index 659081851..4ad8d31e8 100644 --- a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.domain.Project; import org.radarbase.management.domain.Source; import org.radarbase.management.domain.SourceType; @@ -12,6 +11,7 @@ import org.radarbase.management.repository.ProjectRepository; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.security.SecurityUtils; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.ResourceUriService; diff --git a/src/main/java/org/radarbase/management/web/rest/UserResource.java b/src/main/java/org/radarbase/management/web/rest/UserResource.java index 6682a7c29..4624b7d75 100644 --- a/src/main/java/org/radarbase/management/web/rest/UserResource.java +++ b/src/main/java/org/radarbase/management/web/rest/UserResource.java @@ -1,7 +1,6 @@ package org.radarbase.management.web.rest; import io.micrometer.core.annotation.Timed; -import org.radarbase.auth.exception.NotAuthorizedException; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Subject; import org.radarbase.management.domain.User; @@ -9,6 +8,7 @@ import org.radarbase.management.repository.UserRepository; import org.radarbase.management.repository.filters.UserFilter; import org.radarbase.management.security.Constants; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.service.AuthService; import org.radarbase.management.service.MailService; import org.radarbase.management.service.ResourceUriService; diff --git a/src/main/java/org/radarbase/management/web/rest/errors/ExceptionTranslator.java b/src/main/java/org/radarbase/management/web/rest/errors/ExceptionTranslator.java index 832f872d4..795beb9af 100644 --- a/src/main/java/org/radarbase/management/web/rest/errors/ExceptionTranslator.java +++ b/src/main/java/org/radarbase/management/web/rest/errors/ExceptionTranslator.java @@ -1,8 +1,6 @@ package org.radarbase.management.web.rest.errors; -import java.util.List; - -import org.radarbase.auth.exception.NotAuthorizedException; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.web.rest.util.HeaderUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +23,8 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.server.ResponseStatusException; +import java.util.List; + /** * Controller advice to translate the server side exceptions to client-friendly json structures. */ diff --git a/src/test/java/org/radarbase/management/config/MockConfiguration.java b/src/test/java/org/radarbase/management/config/MockConfiguration.java deleted file mode 100644 index 2a4d770db..000000000 --- a/src/test/java/org/radarbase/management/config/MockConfiguration.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2021. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.management.config; - -import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.token.AuthorityReference; -import org.radarbase.auth.token.RadarToken; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; - -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.Set; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.radarbase.auth.authorization.RoleAuthority.SYS_ADMIN; - -@Configuration -public class MockConfiguration { - @Bean - @Primary - public RadarToken radarTokenMock() { - RadarToken token = mock(RadarToken.class); - when(token.getSubject()).thenReturn("admin"); - when(token.getUsername()).thenReturn("admin"); - when(token.isClientCredentials()).thenReturn(false); - when(token.getRoles()).thenReturn(Set.of(new AuthorityReference(SYS_ADMIN))); - when(token.getScopes()).thenReturn(new LinkedHashSet<>(Arrays.asList(Permission.scopes()))); - return token; - } -} diff --git a/src/test/java/org/radarbase/management/config/MockConfiguration.kt b/src/test/java/org/radarbase/management/config/MockConfiguration.kt new file mode 100644 index 000000000..74505f56b --- /dev/null +++ b/src/test/java/org/radarbase/management/config/MockConfiguration.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021. The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * See the file LICENSE in the root of this repository. + */ +package org.radarbase.management.config + +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.DataRadarToken +import org.radarbase.auth.token.RadarToken +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import java.time.Duration +import java.time.Instant + +@Configuration +open class MockConfiguration { + @Bean + @Primary + open fun radarTokenMock(): RadarToken = DataRadarToken( + subject = "admin", + username = "admin", + roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)), + scopes = Permission.scopes().toSet(), + grantType = "password", + expiresAt = Instant.now() + Duration.ofMinutes(30), + ) +} diff --git a/src/test/java/org/radarbase/management/service/UserServiceIntTest.java b/src/test/java/org/radarbase/management/service/UserServiceIntTest.java index 780a6909b..f4fa5bc1f 100644 --- a/src/test/java/org/radarbase/management/service/UserServiceIntTest.java +++ b/src/test/java/org/radarbase/management/service/UserServiceIntTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.radarbase.auth.exception.NotAuthorizedException; +import org.radarbase.management.security.NotAuthorizedException; import org.radarbase.management.ManagementPortalTestApp; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.Role; diff --git a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java index dd680ca2c..df305e1df 100644 --- a/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/SubjectResourceIntTest.java @@ -11,7 +11,6 @@ import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.security.JwtAuthenticationFilter; import org.radarbase.management.service.AuthService; -import org.radarbase.management.service.OrganizationService; import org.radarbase.management.service.SourceService; import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.SubjectService; From 6e22229c9f188c72adb99e25018f131daf907f5d Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 28 Feb 2023 19:38:58 +0100 Subject: [PATCH 007/120] Add separate kotlin-util library --- .dockerignore | 5 +- kotlin-util/.gitignore | 2 + kotlin-util/build.gradle | 55 +++++ .../kotlin/coroutines/CacheConfig.kt | 26 ++ .../radarbase/kotlin/coroutines/CachedMap.kt | 67 +++++ .../radarbase/kotlin/coroutines/CachedSet.kt | 46 ++++ .../kotlin/coroutines/CachedValue.kt | 232 ++++++++++++++++++ .../radarbase/kotlin/coroutines/Extensions.kt | 101 ++++++++ .../kotlin/coroutines/CachedValueTest.kt | 179 ++++++++++++++ .../kotlin/coroutines}/ExtensionsKtTest.kt | 10 +- radar-auth/build.gradle | 4 + .../auth/authentication/TokenValidator.kt | 44 ++-- .../authorization/AuthorityReferenceSet.kt | 2 - .../auth/authorization/AuthorizationOracle.kt | 26 +- .../auth/authorization/EntityDetails.kt | 4 + .../authorization/EntityRelationService.kt | 4 +- .../authorization/MPAuthorizationOracle.kt | 13 +- .../org/radarbase/auth/util/CachedValue.kt | 170 ------------- .../org/radarbase/auth/util/Extensions.kt | 48 ---- .../authorization/RadarAuthorizationTest.kt | 21 +- .../auth/token/AbstractRadarTokenTest.kt | 23 +- settings.gradle | 1 + .../management/service/AuthService.kt | 15 +- 23 files changed, 799 insertions(+), 299 deletions(-) create mode 100644 kotlin-util/.gitignore create mode 100644 kotlin-util/build.gradle create mode 100644 kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt create mode 100644 kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt create mode 100644 kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt create mode 100644 kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt create mode 100644 kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt create mode 100644 kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt rename {radar-auth/src/test/java/org/radarbase/auth/util => kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines}/ExtensionsKtTest.kt (90%) delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt delete mode 100644 radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt diff --git a/.dockerignore b/.dockerignore index 6a0b4bfa9..8ba86059a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,11 @@ build out -radar-auth/build -radar-auth/out radar-auth/src/test +kotlin-util/src/test .idea .gradle .git node_modules -oauth-client-util/build -oauth-client-util/out oauth-client-util/src/test src/test/java src/gatling diff --git a/kotlin-util/.gitignore b/kotlin-util/.gitignore new file mode 100644 index 000000000..3c0160d04 --- /dev/null +++ b/kotlin-util/.gitignore @@ -0,0 +1,2 @@ +build/ +out/ diff --git a/kotlin-util/build.gradle b/kotlin-util/build.gradle new file mode 100644 index 000000000..5e3e66be6 --- /dev/null +++ b/kotlin-util/build.gradle @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'maven-publish' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.dokka' +} + + +sourceCompatibility = JavaVersion.VERSION_11 +targetCompatibility = JavaVersion.VERSION_11 + +description = 'Library for Kotlin utility classes and functions' + + +dependencies { + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) + api("org.jetbrains.kotlinx:kotlinx-coroutines-core") + + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version + testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '2.2' + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: logback_version + testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version +} + + +tasks.withType(KotlinCompile).configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + apiVersion = KotlinVersion.KOTLIN_1_8 + languageVersion = KotlinVersion.KOTLIN_1_8 + } +} + +test { + testLogging { + exceptionFormat = 'full' + } + useJUnitPlatform() +} + +tasks.register('ghPagesJavadoc', Copy) { + from file("$buildDir/dokka/javadoc") + into file("$rootDir/public/radar-auth-javadoc") + dependsOn(dokkaJavadoc) +} + +ext.projectLanguage = "kotlin" + +apply from: "$rootDir/gradle/style.gradle" +apply from: "$rootDir/gradle/publishing.gradle" diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt new file mode 100644 index 000000000..59a6aa3b5 --- /dev/null +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt @@ -0,0 +1,26 @@ +package org.radarbase.kotlin.coroutines + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +data class CacheConfig( + /** Duration after which the cache is considered stale and should be refreshed. */ + val refreshDuration: Duration = 30.minutes, + /** Duration after which the cache may be refreshed if the cache does not fulfill a certain + * requirement. This should be shorter than [refreshDuration] to have effect. */ + val retryDuration: Duration = 1.minutes, + /** Time until the result may be recomputed when an exception is set for the cache. */ + val exceptionCacheDuration: Duration = 10.seconds, + /** + * Number of simultaneous computations that may occur. Increase if the time to computation + * is very variable. + */ + val maxSimultaneousCompute: Int = 1, +) { + init { + require(retryDuration > Duration.ZERO) { "Cache fetch duration $retryDuration must be positive" } + require(refreshDuration >= retryDuration) { "Cache maximum age $refreshDuration must be at least fetch timeout $retryDuration" } + require(maxSimultaneousCompute > 0) { "At least one context must be able to compute the result" } + } +} diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt new file mode 100644 index 000000000..524025151 --- /dev/null +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2020 The Hyve + * + * 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 + * + * http://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.radarbase.kotlin.coroutines + +/** Set of data that is cached for a duration of time. */ +class CachedMap( + cacheConfig: CacheConfig = CacheConfig(), + supplier: suspend () -> Map, +): CachedValue>(cacheConfig, supplier) { + /** Whether the cache contains [key]. If it does not contain the value and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. */ + suspend fun contains(key: K): Boolean = test { key in it } + + /** + * Find a pair matching [predicate]. + * If it does not contain the value and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + * @return value if found and null otherwise + */ + suspend fun find(predicate: (K, V) -> Boolean): Pair? = query( + { map -> + map.entries + .find { (k, v) -> predicate(k, v) } + ?.toPair() + }, + { it != null }, + ).value + + /** + * Find a pair matching [predicate]. + * If it does not contain the value and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + * @return value if found and null otherwise + */ + suspend fun findValue(predicate: (V) -> Boolean): V? = query( + { map -> map.values.find { predicate(it) } }, + { it != null }, + ).value + + /** + * Get the value. + * If the cache is empty and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + */ + override suspend fun get(): Map = get { it.isNotEmpty() }.value + + /** + * Get the value. + * If the cache is empty and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + */ + suspend fun get(key: K): V? = query({ it[key] }, { it != null }).value +} diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt new file mode 100644 index 000000000..361bd89dc --- /dev/null +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Hyve + * + * 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 + * + * http://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.radarbase.kotlin.coroutines + +/** + * Set of data that is cached for a duration of time. + * + * @param supplier How to update the cache. + */ +class CachedSet( + cacheConfig: CacheConfig = CacheConfig(), + supplier: suspend () -> Set, +): CachedValue>(cacheConfig, supplier) { + /** Whether the cache contains [value]. If it does not contain the value and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. */ + suspend fun contains(value: T): Boolean = test { value in it } + + /** + * Find a value matching [predicate]. + * If it does not contain the value and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + * @return value if found and null otherwise + */ + suspend fun find(predicate: (T) -> Boolean): T? = query({ it.find(predicate) }, { it != null }).value + + /** + * Get the value. + * If the cache is empty and [CacheConfig.retryDuration] + * has passed since the last try, it will update the cache and try once more. + */ + override suspend fun get(): Set = get { it.isNotEmpty() }.value +} diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt new file mode 100644 index 000000000..a35220283 --- /dev/null +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt @@ -0,0 +1,232 @@ +package org.radarbase.kotlin.coroutines + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Semaphore +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +internal typealias DeferredCache = CompletableDeferred> + +/** + * Caches a value with full support for coroutines. The value that will be cached is computed by + * [supplier]. + * Only one coroutine context will compute the value at a time, other coroutine contexts will wait + * for it to finish. + */ +open class CachedValue( + private val config: CacheConfig, + private val supplier: suspend () -> T, +) { + private val cache = AtomicReference>>() + private val semaphore: Semaphore? = if (config.maxSimultaneousCompute > 1) { + Semaphore(config.maxSimultaneousCompute - 1) + } else { + null + } + + /** + * Query the cached value by running [transform] and return its result if valid. If + * [evaluate] returns false on the result, the cache computation is reevaluated if + * [CacheConfig.retryDuration] has been reached. + */ + suspend fun query( + transform: suspend (T) -> R, + evaluate: (R) -> Boolean = { true }, + ): CacheResult { + val deferredResult = raceForDeferred() + val deferred = deferredResult.value + + return if (deferredResult is CacheMiss) { + val result = deferred.computeAndCache() + CacheMiss(transform(result)) + } else { + val concurrentResult = deferred.concurrentComputeAndCache() + if (concurrentResult != null) { + CacheMiss(transform(concurrentResult)) + } else { + deferred.awaitCache(transform, evaluate) + } + } + } + + suspend fun isStale(): Boolean { + val currentDeferred = cache.get() ?: return true + if (!currentDeferred.isCompleted) return false + return currentDeferred.await().isExpired { true } + } + + /** + * Get cached value. If the cache is expired, fetch it again. The first coroutine context + * that reaches this method will call [computeAndCache], others coroutine contexts will use the + * value computed by the first. The result is not computed more + * often than [CacheConfig.retryDuration]. If the result was an exception, the exception is + * rethrown from cache. It is recomputed if the [CacheConfig.exceptionCacheDuration] has passed. + */ + open suspend fun get(): T = query({ it }) { false }.value + + /** + * Get cached value. If the cache is expired, fetch it again. The first coroutine context + * that reaches this method will call [computeAndCache], others coroutine contexts will use the + * value computed by the first. If the value was retrieved from cache and [evaluate] + * returns false for that value, the result is recomputed. The result is not computed more + * often than [CacheConfig.retryDuration]. If the result was an exception, the exception is + * rethrown from cache. It is recomputed if the [CacheConfig.exceptionCacheDuration] has passed. + */ + suspend inline fun get(noinline evaluate: (T) -> Boolean): CacheResult = query({ it }, evaluate) + + /** + * Test the cached value by running [predicate] and return its result if true. If + * [predicate] returns false on the result, the cache computation is reevaluated if + * [CacheConfig.retryDuration] has been reached. + */ + suspend inline fun test(noinline predicate: (T) -> Boolean): Boolean { + return query(predicate) { it }.value + } + + private suspend fun DeferredCache.computeAndCache(): T { + val result = try { + val value = supplier() + complete(CacheValue(value)) + value + } catch (ex: Throwable) { + complete(CacheError(ex)) + throw ex + } + return result + } + + private suspend fun DeferredCache.concurrentComputeAndCache(): T? { + if (isCompleted) return null + + return semaphore?.tryWithPermitOrNull { + if (isCompleted) { + null + } else { + computeAndCache() + } + } + } + + private suspend fun DeferredCache.awaitCache( + transform: suspend (T) -> R, + evaluate: (R) -> Boolean, + ): CacheResult { + val result = await().map(transform) + return if (result.isExpired(evaluate)) { + // Either no new coroutine context had updated the cache value, then update it to + // null. Otherwise, another suspend context is active and get() will await the + // result from that context + cache.compareAndSet(this, null) + query(transform) { false } + } else { + val value = result.getOrThrow() + CacheHit(value) + } + } + + /** + * Race for the first suspend context to create a CompletableDeferred object. All other contexts + * will use that context to read their values. + * + * @return a pair of a CompletableDeferred value and a boolean, if true this context is the + * winner, if false this should use the deferred to read its value. + */ + private fun raceForDeferred(): CacheResult> { + var result: CacheResult> + + do { + val previousDeferred = cache.get() + result = if (previousDeferred == null) { + CacheMiss(CompletableDeferred()) + } else { + CacheHit(previousDeferred) + } + } while (!cache.compareAndSet(previousDeferred, result.value)) + + return result + } + + private inline fun CacheContents.isExpired(evaluate: (R) -> Boolean): Boolean = when { + this is CacheError -> exception is CancellationException || + isExpired(config.exceptionCacheDuration) + this is CacheValue && !evaluate(value) -> isExpired(config.retryDuration) + else -> isExpired(config.refreshDuration) + } + + fun clear() { + cache.set(null) + } + + @OptIn(ExperimentalTime::class) + internal sealed class CacheContents( + initialDuration: Duration? = null, + time: TimeMark? = null, + ) { + protected val time: TimeMark + + init { + var now = time ?: TimeSource.Monotonic.markNow() + if (initialDuration != null) { + now -= initialDuration + } + this.time = now + } + + @Volatile + private var isExpired = false + + fun isExpired(age: Duration): Boolean = when { + isExpired -> true + (time + age).hasPassedNow() -> { + isExpired = true + true + } + else -> false + } + + abstract fun getOrThrow(): T + + @Suppress("UNCHECKED_CAST") + abstract suspend fun map(transform: suspend (T) -> R): CacheContents + } + + @OptIn(ExperimentalTime::class) + internal class CacheError( + val exception: Throwable, + ) : CacheContents() { + override fun getOrThrow(): T = throw exception + @Suppress("UNCHECKED_CAST") + override suspend fun map(transform: suspend (T) -> R): CacheContents = this as CacheError + } + + @OptIn(ExperimentalTime::class) + internal class CacheValue( + val value: T, + initialDuration: Duration? = null, + time: TimeMark? = null, + ) : CacheContents(initialDuration, time) { + override fun getOrThrow(): T = value + + override suspend fun map(transform: suspend (T) -> R): CacheContents = try { + CacheValue(transform(value), time = time) + } catch (ex: Throwable) { + CacheError(ex) + } + } + + sealed interface CacheResult { + val value: T + } + + data class CacheHit(override val value: T) : CacheResult + data class CacheMiss(override val value: T) : CacheResult + + companion object { + private val logger = LoggerFactory.getLogger(CachedValue::class.java) + } +} diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt new file mode 100644 index 000000000..eff901c2f --- /dev/null +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt @@ -0,0 +1,101 @@ +package org.radarbase.kotlin.coroutines + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consume +import kotlinx.coroutines.sync.Semaphore +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Try to acquire a semaphore permit, and run [block] if successful. + * If this cannot be achieved without blocking, return null. + * @return result of [block] or null if no permit could be acquired. + */ +suspend fun Semaphore.tryWithPermitOrNull(block: suspend () -> T): T? { + if (!tryAcquire()) return null + return try { + block() + } finally { + release() + } +} + +/** + * Transform each value in the iterable in a separate coroutine and await termination. + */ +suspend inline fun Iterable.forkJoin( + coroutineContext: CoroutineContext = Dispatchers.Default, + crossinline transform: suspend CoroutineScope.(T) -> R +): List = coroutineScope { + map { t -> async(coroutineContext) { transform(t) } } + .awaitAll() +} + +/** + * Consume the first value produced by the producer on its provided channel. Once a value is sent + * by the producer, its coroutine is cancelled. + * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not + * produce any values. + */ +suspend inline fun consumeFirst( + coroutineContext: CoroutineContext = Dispatchers.Default, + crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit +): T = coroutineScope { + val channel = Channel() + + val producerJob = launch(coroutineContext) { + try { + producer(channel::send) + } finally { + channel.close() + } + } + + val result = channel.consume { receive() } + producerJob.cancel() + result +} + +/** + * Transforms each value with [transform] and returns the first value where [predicate] returns + * true. Each value is transformed and evaluated in its own async context. If no transformed value + * satisfies predicate, null is returned. + */ +suspend fun Iterable.forkFirstOfOrNull( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + transform: suspend CoroutineScope.(T) -> R, + predicate: suspend CoroutineScope.(R) -> Boolean, +): R? = consumeFirst(coroutineContext) { emit -> + forkJoin(coroutineContext) { t -> + val result = transform(t) + if (predicate(result)) { + emit(result) + } + } + emit(null) +} + +suspend fun Iterable.forkFirstOfNotNullOrNull( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + transform: suspend CoroutineScope.(T) -> R? +): R? = forkFirstOfOrNull(coroutineContext, transform) { it != null } + +/** + * Returns true as soon as [predicate] returns true on a value, or false if [predicate] does + * not return true on any of the values. All values are evaluated in a separate async context using + * [forkJoin]. + */ +suspend fun Iterable.forkAny( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + predicate: suspend CoroutineScope.(T) -> Boolean +): Boolean = forkFirstOfOrNull(coroutineContext, predicate) { it } ?: false + +operator fun Set.plus(elements: Set): Set = when { + isEmpty() -> elements + elements.isEmpty() -> this + else -> buildSet(size + elements.size) { + addAll(this) + addAll(elements) + } +} diff --git a/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt b/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt new file mode 100644 index 000000000..f9d1400eb --- /dev/null +++ b/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt @@ -0,0 +1,179 @@ +package org.radarbase.kotlin.coroutines + +import kotlinx.coroutines.* +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.slf4j.LoggerFactory +import java.time.Duration +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +@OptIn(ExperimentalTime::class, DelicateCoroutinesApi::class) +internal class CachedValueTest { + private lateinit var config: CacheConfig + + private val calls: AtomicInteger = AtomicInteger(0) + + @BeforeEach + fun setUp() { + calls.set(0) + config = CacheConfig( + refreshDuration = 20.milliseconds, + retryDuration = 10.milliseconds, + exceptionCacheDuration = 10.milliseconds + ) + } + + @Test + fun get() { + val cache = CachedValue(config) { calls.incrementAndGet() } + runBlocking(GlobalScope.coroutineContext) { + assertThat("Initial value should refresh", cache.get(), `is`(1)) + assertThat("No refresh within threshold", cache.get(), `is`(1)) + delay(10) + assertThat("Refresh after threshold", cache.get(), `is`(2)) + assertThat("No refresh after threshold", cache.get(), `is`(2)) + } + } + + @Test + fun getInvalid() { + val cache = CachedValue(config) { calls.incrementAndGet() } + runBlocking { + assertThat("Initial value should refresh", cache.get { it < 0 }, equalTo(CachedValue.CacheMiss(1))) + assertThat("No refresh within threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheHit(1))) + delay(10) + assertThat("Refresh after threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheMiss(2))) + assertThat("No refresh after threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheHit(2))) + } + } + + @Test + fun getValid() { + val cache = CachedValue(config) { calls.incrementAndGet() } + runBlocking { + assertThat("Initial value should refresh", cache.get { it >= 0 }, equalTo(CachedValue.CacheMiss(1))) + assertThat("No refresh within threshold", cache.get { it >= 0 }, equalTo(CachedValue.CacheHit(1))) + delay(10) + assertThat("No refresh after valid value", cache.get { it >= 0 }, equalTo(CachedValue.CacheHit(1))) + } + } + + @Test + fun refresh() { + val cache = CachedValue(config) { calls.incrementAndGet() } + + runBlocking { + assertThat("Initial get calls supplier", cache.get(), `is`(1)) + assertThat("Next get uses cache", cache.get(), `is`(1)) + cache.clear() + assertThat("Next get uses cache", cache.get(), `is`(2)) + } + } + + @Test + fun query() { + val cache = CachedValue(config) { calls.incrementAndGet() } + + runBlocking { + assertThat("Initial value should refresh", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheMiss(2))) + assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheHit(2))) + delay(10) + assertThat( + "Retry because predicate does not match", + cache.query({ it + 1 }, { it > 2 }), + equalTo(CachedValue.CacheMiss(3)) + ) + assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheHit(3))) + delay(10) + assertThat( + "No retry because predicate matches", + cache.query({ it + 1 }, { it > 2 }), + equalTo(CachedValue.CacheHit(3)) + ) + delay(10) + assertThat( + "Refresh after refresh threshold since last retry", + cache.query({ it + 1 }, { it > 2 }), + equalTo(CachedValue.CacheMiss(4)) + ) + } + } + + + @Test + fun getMultithreaded() { + val cache = CachedValue(config) { + calls.incrementAndGet() + delay(50.milliseconds) + calls.get() + } + + runBlocking { + (0 .. 5) + .forkJoin { + cache.get() + } + .forEach { + assertThat("Get the same value in all contexts", it, `is`(1)) + } + } + + assertThat("No more calls are made", calls.get(), `is`(1)) + } + + @Test + fun getMulti2threaded() { + val cache = CachedValue(config.copy( + maxSimultaneousCompute = 2 + )) { + calls.incrementAndGet() + delay(50.milliseconds) + calls.get() + } + + runBlocking { + val values = (0 .. 5) + .forkJoin { + cache.get() + } + + assertThat(values[0], lessThan(3)) + values.forEach { + assertThat("Get the same value in all contexts", it, `is`(values[0])) + } + } + + assertThat("Two threads should be computing the value", calls.get(), `is`(2)) + } + + + @Test + fun throwTest() { + val cache = CachedValue(config.copy(refreshDuration = 20.milliseconds)) { + val newValue = calls.incrementAndGet() + if (newValue % 2 == 0) throw IllegalStateException() else newValue + } + + runBlocking { + assertThat(cache.get(), `is`(1)) + assertThat(cache.get(), `is`(1)) + delay(21.milliseconds) + assertThrows { cache.get() } + assertThrows { cache.get() } + delay(11.milliseconds) + assertThat(cache.get(), `is`(3)) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(CachedValueTest::class.java) + } +} diff --git a/radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt b/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt similarity index 90% rename from radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt rename to kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt index 58e996aae..7cb9a18c2 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/util/ExtensionsKtTest.kt +++ b/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt @@ -1,4 +1,4 @@ -package org.radarbase.auth.util +package org.radarbase.kotlin.coroutines import kotlinx.coroutines.* import org.hamcrest.MatcherAssert.assertThat @@ -77,4 +77,12 @@ class ExtensionsKtTest { assertThat(inBlockingTime, lessThan(200.milliseconds)) assertThat(inBlockingTime, greaterThan(50.milliseconds)) } + + @Test + fun testConcurrentAny() { + runBlocking { + assertTrue(listOf(1, 2, 3, 4).forkAny { it > 3 }) + assertFalse(listOf(1, 2, 3, 4).forkAny { it < 1 }) + } + } } diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index 5cc77f19f..3617ae599 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -16,6 +16,10 @@ description = 'Library for authentication and authorization of JWT tokens issued dependencies { api group: 'com.auth0', name: 'java-jwt', version: oauth_jwt_version + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) + api("org.jetbrains.kotlinx:kotlinx-coroutines-core") + + implementation(project(":kotlin-util")) implementation(platform('io.ktor:ktor-bom:2.2.3')) implementation("io.ktor:ktor-client-core") diff --git a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt index 58800dc39..6e8dfd77b 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authentication/TokenValidator.kt @@ -4,20 +4,19 @@ import com.auth0.jwt.exceptions.AlgorithmMismatchException import kotlinx.coroutines.* import org.radarbase.auth.exception.TokenValidationException import org.radarbase.auth.token.RadarToken -import org.radarbase.auth.util.CachedValue -import org.radarbase.auth.util.consumeFirst -import org.radarbase.auth.util.forkJoin +import org.radarbase.kotlin.coroutines.CacheConfig +import org.radarbase.kotlin.coroutines.CachedValue +import org.radarbase.kotlin.coroutines.consumeFirst +import org.radarbase.kotlin.coroutines.forkJoin import org.slf4j.LoggerFactory import java.time.Duration +import kotlin.time.toKotlinDuration private typealias TokenVerifierCache = CachedValue> /** - * Validates JWT token signed by the Management Portal. It may be used from multiple threads. - * If the status of the public key should be checked immediately, call - * [.refresh] directly after creating this validator. It currently does not check this, so - * that the validator can be used even if a remote ManagementPortal is not reachable during - * construction. + * Validates JWT token signed by the Management Portal. It may be used from multiple coroutine + * contexts. */ class TokenValidator @JvmOverloads @@ -29,13 +28,16 @@ constructor( /** Maximum time that the token verifier does not need to be fetched. */ maxAge: Duration = Duration.ofDays(1), ) { - private val algorithmLoaders: List = verifierLoaders.map { loader -> - CachedValue( - retryDuration = fetchTimeout, - refreshDuration = maxAge, + private val algorithmLoaders: List + + init { + val config = CacheConfig( + retryDuration = fetchTimeout.toKotlinDuration(), + refreshDuration = maxAge.toKotlinDuration(), maxSimultaneousCompute = 2, - ) { - loader.fetch() + ) + algorithmLoaders = verifierLoaders.map { loader -> + CachedValue(config, supplier = loader::fetch) } } @@ -74,7 +76,7 @@ constructor( @Throws(TokenValidationException::class) suspend fun validate(token: String): RadarToken { val result: Result = consumeFirst { emit -> - val errors = algorithmLoaders + val causes = algorithmLoaders .forkJoin { cache -> val result = cache.verify(token) // short-circuit to return the first successful result @@ -87,11 +89,13 @@ constructor( ?: emptyList() } - val suppressedMessage = errors.joinToString { it.message ?: it.javaClass.simpleName } - emit( - TokenValidationException("No registered validator in could authenticate this token: $suppressedMessage") - .toFailure(errors) - ) + val message = if (causes.isEmpty()) { + "No registered validator in could authenticate this token" + } else { + val suppressedMessage = causes.joinToString { it.message ?: it.javaClass.simpleName } + "No registered validator in could authenticate this token: $suppressedMessage" + } + emit(TokenValidationException(message).toFailure(causes)) } return result.getOrThrow() diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt index 45daa0caf..345f9da8c 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorityReferenceSet.kt @@ -1,7 +1,5 @@ package org.radarbase.auth.authorization -import org.radarbase.auth.util.plus - data class AuthorityReferenceSet( /** Identity has global authority. */ val global: Boolean = false, diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt index 6826b264e..2233c0329 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt @@ -6,26 +6,10 @@ import java.util.function.Consumer interface AuthorizationOracle { /** - * Whether [identity] has permission [permission], regarding given [entity]. An additional - * [entityScope] can be provided to check whether the permission is also valid regarding that - * scope. The permission is checked both for its - * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @return true if identity has permission, false otheriwse - */ - fun hasPermission( - identity: RadarToken, - permission: Permission, - entityBuilder: Consumer, - ): Boolean = hasPermission(identity, permission, EntityDetails().apply(entityBuilder::accept)) - - /** - * Whether [identity] has permission [permission], regarding given [entity]. An additional - * [entityScope] can be provided to check whether the permission is also valid regarding that - * scope. The permission is checked both for its - * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @return true if identity has permission, false otheriwse + * Whether [identity] has permission [permission] on a global level. + * @return true if identity has permission, false otherwise */ - fun hasGlobalPermission( + suspend fun hasGlobalPermission( identity: RadarToken, permission: Permission, ): Boolean = hasPermission(identity, permission) @@ -35,9 +19,9 @@ interface AuthorizationOracle { * [entityScope] can be provided to check whether the permission is also valid regarding that * scope. The permission is checked both for its * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. - * @return true if identity has permission, false otheriwse + * @return true if identity has permission, false otherwise */ - fun hasPermission( + suspend fun hasPermission( identity: RadarToken, permission: Permission, entity: EntityDetails = EntityDetails.global, diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt index 934aefda3..222c589a4 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityDetails.kt @@ -1,6 +1,7 @@ package org.radarbase.auth.authorization import java.util.function.Consumer +import kotlin.math.min /** Entity details to check with AuthorizationOracle. */ data class EntityDetails( @@ -28,6 +29,9 @@ data class EntityDetails( else -> null } + val isGlobal: Boolean + get() = minimumEntityOrNull() == null + fun organization(organization: String?) = apply { this.organization = organization } diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt index f35b43cf3..8978b812d 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt @@ -3,9 +3,9 @@ package org.radarbase.auth.authorization /** Service to determine the relationship between entities. */ interface EntityRelationService { /** From a [project] name, return an organization name. */ - fun findOrganizationOfProject(project: String): String + suspend fun findOrganizationOfProject(project: String): String /** Whether given [organization] name has a [project] with given name. */ - fun organizationContainsProject(organization: String, project: String): Boolean = + suspend fun organizationContainsProject(organization: String, project: String): Boolean = findOrganizationOfProject(project) == organization } diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt index b9fbaf888..25e1e53d0 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt @@ -1,6 +1,7 @@ package org.radarbase.auth.authorization import org.radarbase.auth.token.RadarToken +import org.radarbase.kotlin.coroutines.forkAny import java.util.* class MPAuthorizationOracle( @@ -13,7 +14,7 @@ class MPAuthorizationOracle( * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. * @return true if identity has permission, false otheriwse */ - override fun hasPermission( + override suspend fun hasPermission( identity: RadarToken, permission: Permission, entity: EntityDetails, @@ -23,7 +24,7 @@ class MPAuthorizationOracle( if (identity.isClientCredentials) return true - return identity.roles.any { + return identity.roles.forkAny { it.hasPermission(identity, permission, entity, entityScope) } } @@ -92,7 +93,7 @@ class MPAuthorizationOracle( * Whether the current role from [identity] has [permission] over given [entity] in * [entityScope] in any way. */ - private fun AuthorityReference.hasPermission( + private suspend fun AuthorityReference.hasPermission( identity: RadarToken, permission: Permission, entity: EntityDetails, @@ -112,7 +113,7 @@ class MPAuthorizationOracle( * Whether the current role from [identity] has a specific authority with [permission] * over given [entity] in [entityScope] */ - private fun AuthorityReference.hasAuthority( + private suspend fun AuthorityReference.hasAuthority( identity: RadarToken, permission: Permission, entity: EntityDetails, @@ -143,14 +144,14 @@ class MPAuthorizationOracle( else -> true } - private fun EntityDetails.findOrganization(): String? { + private suspend fun EntityDetails.findOrganization(): String? { organization?.let { return it } val p = project ?: return null return relationService.findOrganizationOfProject(p) .also { this.organization = it } } - private fun EntityDetails.organizationContainsProject(targetProject: String): Boolean { + private suspend fun EntityDetails.organizationContainsProject(targetProject: String): Boolean { val org = findOrganization() ?: return false return relationService.organizationContainsProject(org, targetProject) } diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt b/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt deleted file mode 100644 index ba9c901d8..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/util/CachedValue.kt +++ /dev/null @@ -1,170 +0,0 @@ -package org.radarbase.auth.util - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.sync.Semaphore -import java.time.Duration -import java.time.Instant -import java.util.concurrent.atomic.AtomicReference - -internal typealias DeferredCache = CompletableDeferred> - -/** - * Caches a value with full support for coroutines. - * Only one coroutine context will compute the value at a time, other coroutine contexts will wait - * for it to finish. - */ -class CachedValue( - /** Duration after which the cache is considered stale and should be refreshed. */ - val refreshDuration: Duration = Duration.ofMinutes(30), - /** Duration after which the cache may be refreshed if the cache does not fulfill a certain - * requirement. This should be shorter than [refreshDuration] to have effect. */ - val retryDuration: Duration = Duration.ofMinutes(1), - /** Time to wait for a lock to come free when an exception is set for the cache. */ - val exceptionLockDuration: Duration = Duration.ofSeconds(10), - /** - * Number of simultaneous computations that may occur. Increase if the time to computation - * is very variable. - */ - val maxSimultaneousCompute: Int = 1, - private val compute: suspend () -> T, -) { - - private val cache = AtomicReference>>() - private val semaphore: Semaphore? = if (maxSimultaneousCompute > 1) { - Semaphore(maxSimultaneousCompute - 1) - } else { - null - } - - init { - require(retryDuration > Duration.ZERO) { "Cache fetch duration $retryDuration must be positive" } - require(refreshDuration >= retryDuration) { "Cache maximum age $refreshDuration must be at least fetch timeout $retryDuration" } - require(maxSimultaneousCompute > 0) { "At least one context must be able to compute the result" } - } - - /** - * Get cached value. If the cache is expired, fetch it again. The first coroutine context - * that reaches this method will call [computeAndCache], others coroutine contexts will use the value - * computed by the first. - */ - suspend fun get(retryCondition: (T) -> Boolean = { false }): CacheResult { - val deferredResult = raceForDeferred() - val deferred = deferredResult.value - - return if (deferredResult is CacheMiss) { - deferred.computeAndCache() - } else { - deferred.concurrentComputeAndCache() - ?: deferred.awaitCache(retryCondition) - } - } - - private suspend fun DeferredCache.computeAndCache(): CacheResult { - val result = try { - val value = compute() - complete(CacheValue(value)) - value - } catch (ex: Throwable) { - complete(CacheError(ex)) - throw ex - } - return CacheMiss(result) - } - - private suspend fun DeferredCache.awaitCache(retry: (T) -> Boolean): CacheResult { - val result = await() - return if (result.isExpired(retry)) { - // Either no new coroutine context had updated the cache value, then update it to - // null. Otherwise, another suspend context is active and get() will await the - // result from that context - cache.compareAndSet(this, null) - get() - } else { - val value = result.getOrThrow() - CacheHit(value) - } - } - - private suspend fun DeferredCache.concurrentComputeAndCache(): CacheResult? { - semaphore ?: return null - if (isCompleted || !semaphore.tryAcquire()) return null - - return try { - if (isCompleted) { - null - } else { - computeAndCache() - } - } finally { - semaphore.release() - } - } - - /** - * Race for the first suspend context to create a CompletableDeferred object. All other contexts - * will use that context to read their values. - * - * @return a pair of a CompletableDeferred value and a boolean, if true this context is the - * winner, if false this should use the deferred to read its value. - */ - private fun raceForDeferred(): CacheResult>> { - var result: CacheResult>> - - do { - val previousDeferred = cache.get() - result = if (previousDeferred == null) { - CacheMiss(CompletableDeferred()) - } else { - CacheHit(previousDeferred) - } - } while (!cache.compareAndSet(previousDeferred, result.value)) - - return result - } - - private fun CacheContents.isExpired(retry: (T) -> Boolean): Boolean = when { - this is CacheError -> exception is CancellationException || isExpired(exceptionLockDuration) - this is CacheValue && retry(value) -> isExpired(retryDuration) - else -> isExpired(refreshDuration) - } - - fun clear() { - cache.set(null) - } - - internal sealed class CacheContents { - val time: Instant = Instant.now() - - @Volatile - private var isExpired = false - - fun isExpired(age: Duration): Boolean = when { - isExpired -> true - Instant.now() > time + age -> { - isExpired = true - true - } - else -> false - } - - fun getOrThrow() = when (this) { - is CacheValue -> value - is CacheError -> throw exception - } - } - - private class CacheError( - val exception: Throwable, - ): CacheContents() - - private class CacheValue( - val value: T, - ): CacheContents() - - sealed interface CacheResult { - val value: T - } - data class CacheHit(override val value: T): CacheResult - data class CacheMiss(override val value: T): CacheResult -} diff --git a/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt b/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt deleted file mode 100644 index 23d6490f2..000000000 --- a/radar-auth/src/main/java/org/radarbase/auth/util/Extensions.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.radarbase.auth.util - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.consume -import kotlin.coroutines.CoroutineContext - -/** - * Transform each value in the iterable in a separate coroutine and await termination. - */ -internal suspend inline fun Iterable.forkJoin( - coroutineContext: CoroutineContext = Dispatchers.Default, - crossinline transform: suspend CoroutineScope.(T) -> R -): List = coroutineScope { - map { t -> async(coroutineContext) { transform(t) } } - .awaitAll() -} - -/** - * Consume the first value produced by the producer on its provided channel. Once a value is sent - * by the producer, its coroutine is cancelled. - * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not - * produce any values. - */ -internal suspend inline fun consumeFirst( - coroutineContext: CoroutineContext = Dispatchers.Default, - crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit -): T = coroutineScope { - val channel = Channel() - - val producerJob = launch(coroutineContext) { - producer(channel::send) - channel.close() - } - - val result = channel.consume { receive() } - producerJob.cancel() - result -} - -internal operator fun Set.plus(elements: Set): Set = when { - isEmpty() -> elements - elements.isEmpty() -> this - else -> buildSet(size + elements.size) { - addAll(this) - addAll(elements) - } -} diff --git a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt index c6e2d71ce..a0d101e9b 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt +++ b/radar-auth/src/test/java/org/radarbase/auth/authorization/RadarAuthorizationTest.kt @@ -1,5 +1,6 @@ package org.radarbase.auth.authorization +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -23,7 +24,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnProject() { + fun testCheckPermissionOnProject() = runBlocking { val project = "PROJECT1" // let's get all permissions a project admin has val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() @@ -48,7 +49,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnOrganization() { + fun testCheckPermissionOnOrganization() = runBlocking { val token = TokenTestUtils.ORGANIZATION_ADMIN_TOKEN.toRadarToken() val entity = EntityDetails(organization = "main") assertFalse( @@ -82,7 +83,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermission() { + fun testCheckPermission() = runBlocking { val token: RadarToken = TokenTestUtils.SUPER_USER_TOKEN.toRadarToken() for (p in Permission.values()) { assertTrue(oracle.hasGlobalPermission(token, p)) @@ -90,7 +91,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnSelf() { + fun testCheckPermissionOnSelf() = runBlocking { // this token is participant in PROJECT2 val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() val entity = EntityDetails(project = "PROJECT2", subject = token.subject) @@ -103,7 +104,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnOtherSubject() { + fun testCheckPermissionOnOtherSubject() = runBlocking { // is only participant in project2, so should not have any permission on another subject val entity = EntityDetails(project = "PROJECT2", subject = "other-subject") // this token is participant in PROJECT2 @@ -117,7 +118,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnSubject() { + fun testCheckPermissionOnSubject() = runBlocking { // project admin should have all permissions on subject in his project // this token is participant in PROJECT2 val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() @@ -131,7 +132,7 @@ internal class RadarAuthorizationTest { } @Test - fun testMultipleRolesInProjectToken() { + fun testMultipleRolesInProjectToken() = runBlocking { val token: RadarToken = TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN.toRadarToken() val entity = EntityDetails(project = "PROJECT2", subject = "some-subject") Permission.values() @@ -143,7 +144,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnSource() { + fun testCheckPermissionOnSource() = runBlocking { // this token is participant in PROJECT2 val token: RadarToken = TokenTestUtils.PROJECT_ADMIN_TOKEN.toRadarToken() val entity = EntityDetails(project = "PROJECT2", subject = "some-subject", source = "source-1") @@ -157,7 +158,7 @@ internal class RadarAuthorizationTest { } @Test - fun testCheckPermissionOnOwnSource() { + fun testCheckPermissionOnOwnSource() = runBlocking { val token: RadarToken = TokenTestUtils.MULTIPLE_ROLES_IN_PROJECT_TOKEN.toRadarToken() val entity = EntityDetails(project = "PROJECT2", subject = token.subject, source = "source-1") Permission.values() @@ -169,7 +170,7 @@ internal class RadarAuthorizationTest { } @Test - fun testScopeOnlyToken() { + fun testScopeOnlyToken() = runBlocking { val token: RadarToken = TokenTestUtils.SCOPE_TOKEN.toRadarToken() // test that we can do the things we have a scope for val entities = listOf( diff --git a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt index a61871aa0..ecef256cc 100644 --- a/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt +++ b/radar-auth/src/test/java/org/radarbase/auth/token/AbstractRadarTokenTest.kt @@ -1,5 +1,6 @@ package org.radarbase.auth.token +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -22,7 +23,7 @@ class AbstractRadarTokenTest { class MockEntityRelationService @JvmOverloads constructor( private val projectToOrganization: Map = mapOf() ) : EntityRelationService { - override fun findOrganizationOfProject(project: String): String { + override suspend fun findOrganizationOfProject(project: String): String { return projectToOrganization[project] ?: "main" } } @@ -74,7 +75,7 @@ class AbstractRadarTokenTest { } @Test - fun notHasPermissionOnProjectWithoutScope() { + fun notHasPermissionOnProjectWithoutScope() = runBlocking { assertFalse( oracle.hasPermission( token, @@ -85,7 +86,7 @@ class AbstractRadarTokenTest { } @Test - fun notHasPermissioOnProjectnWithoutAuthority() { + fun notHasPermissioOnProjectnWithoutAuthority() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()) ) @@ -99,7 +100,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnProjectAsAdmin() { + fun hasPermissionOnProjectAsAdmin() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)), @@ -114,7 +115,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnProjectAsUser() { + fun hasPermissionOnProjectAsUser() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), roles = setOf(AuthorityReference(RoleAuthority.PARTICIPANT, "project")), @@ -135,7 +136,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnProjectAsClient() { + fun hasPermissionOnProjectAsClient() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), grantType = CLIENT_CREDENTIALS, @@ -150,7 +151,7 @@ class AbstractRadarTokenTest { } @Test - fun notHasPermissionOnSubjectWithoutScope() { + fun notHasPermissionOnSubjectWithoutScope() = runBlocking { assertFalse( oracle.hasPermission( token, @@ -161,7 +162,7 @@ class AbstractRadarTokenTest { } @Test - fun notHasPermissioOnSubjectnWithoutAuthority() { + fun notHasPermissioOnSubjectnWithoutAuthority() = runBlocking { token = token.copy(scopes = setOf(Permission.MEASUREMENT_CREATE.scope())) assertFalse( oracle.hasPermission( @@ -173,7 +174,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnSubjectAsAdmin() { + fun hasPermissionOnSubjectAsAdmin() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), roles = setOf(AuthorityReference(RoleAuthority.SYS_ADMIN)), @@ -188,7 +189,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnSubjectAsUser() { + fun hasPermissionOnSubjectAsUser() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), roles = setOf(AuthorityReference(RoleAuthority.PARTICIPANT, "project")), @@ -211,7 +212,7 @@ class AbstractRadarTokenTest { } @Test - fun hasPermissionOnSubjectAsClient() { + fun hasPermissionOnSubjectAsClient() = runBlocking { token = token.copy( scopes = setOf(Permission.MEASUREMENT_CREATE.scope()), grantType = CLIENT_CREDENTIALS diff --git a/settings.gradle b/settings.gradle index bb323b41a..b590d9a59 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,4 @@ rootProject.name = 'management-portal' include ':oauth-client-util' include ':radar-auth' include ':managementportal-client' +include ':kotlin-util' diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index bf103d41f..bbfb065bf 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -1,5 +1,8 @@ package org.radarbase.management.service +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.radarbase.auth.authorization.* import org.radarbase.auth.token.RadarToken import org.radarbase.management.security.NotAuthorizedException @@ -18,8 +21,8 @@ open class AuthService { private val oracle: AuthorizationOracle = MPAuthorizationOracle( object : EntityRelationService { - override fun findOrganizationOfProject(project: String): String { - return projectService.findOneByName(project).organization.name + override suspend fun findOrganizationOfProject(project: String): String = withContext(Dispatchers.IO) { + projectService.findOneByName(project).organization.name } } ) @@ -56,10 +59,14 @@ open class AuthService { val token = token ?: throw NotAuthorizedException("User without authentication does not have permission.") val entity = if (builder != null) entityDetailsBuilder(builder) else EntityDetails.global - if (!oracle.hasPermission(token, permission, entity, scope)) { + + val hasPermission = runBlocking { + oracle.hasPermission(token, permission, entity, scope) + } + if (!hasPermission) { throw NotAuthorizedException( "User ${token.username} with client ${token.clientId} does not have permission $permission to scope " + - "$token of $entity" + "$scope of $entity" ) } } From 54ad101a23728687b5e160f512cd48c7e1d4ca4b Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 1 Mar 2023 10:59:05 +0100 Subject: [PATCH 008/120] Added suspended get for futures --- .../radarbase/kotlin/coroutines/Extensions.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt index eff901c2f..87cbf27f7 100644 --- a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt @@ -1,11 +1,16 @@ +@file:Suppress("unused") + package org.radarbase.kotlin.coroutines import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consume import kotlinx.coroutines.sync.Semaphore +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext +import kotlin.time.Duration /** * Try to acquire a semaphore permit, and run [block] if successful. @@ -21,6 +26,36 @@ suspend fun Semaphore.tryWithPermitOrNull(block: suspend () -> T): T? { } } +/** + * Get a future value via coroutine suspension. + * The future is evaluated in context [Dispatchers.IO]. + */ +suspend fun Future.suspendGet( + duration: Duration? = null, +): T = coroutineScope { + val channel = Channel() + launch { + try { + channel.receive() + } catch (ex: CancellationException) { + cancel(true) + } + } + try { + withContext(Dispatchers.IO) { + if (duration != null) { + get(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } else { + get() + } + } + } catch (ex: InterruptedException) { + throw CancellationException("Future was interrupted", ex) + } finally { + channel.send(Unit) + } +} + /** * Transform each value in the iterable in a separate coroutine and await termination. */ From f1bf418b6feec04b2b7a776339404e1854dece03 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 2 Mar 2023 15:55:35 +0100 Subject: [PATCH 009/120] Added group to client MPSubject --- .../main/kotlin/org/radarbase/management/client/MPSubject.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt index aa54436bd..2548b9d95 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt @@ -18,6 +18,8 @@ data class MPSubject( val externalLink: String? = null, /** User status in the project. */ val status: String = "DEACTIVATED", + /** Group of the subject. */ + val group: String? = null, /** Additional attributes of the user. */ val attributes: Map = emptyMap(), ) From aee7364260e0e03b48f8018c01c0336d2bfa6a5e Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 2 Mar 2023 15:56:11 +0100 Subject: [PATCH 010/120] Ensure that projects return their enclosing organization --- .../org/radarbase/management/repository/ProjectRepository.java | 1 + .../org/radarbase/management/repository/SubjectRepository.java | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/repository/ProjectRepository.java b/src/main/java/org/radarbase/management/repository/ProjectRepository.java index 2a698e5d9..a6f81ee60 100644 --- a/src/main/java/org/radarbase/management/repository/ProjectRepository.java +++ b/src/main/java/org/radarbase/management/repository/ProjectRepository.java @@ -46,6 +46,7 @@ List findAllByOrganizationName( @Query("select project from Project project " + "left join fetch project.sourceTypes s " + "left join fetch project.groups " + + "left join fetch project.organization " + "where project.id = :id") Optional findOneWithEagerRelationships(@Param("id") Long id); diff --git a/src/main/java/org/radarbase/management/repository/SubjectRepository.java b/src/main/java/org/radarbase/management/repository/SubjectRepository.java index 26dfb12e6..d4d76670a 100644 --- a/src/main/java/org/radarbase/management/repository/SubjectRepository.java +++ b/src/main/java/org/radarbase/management/repository/SubjectRepository.java @@ -41,7 +41,8 @@ Page findAllByProjectNameAndAuthoritiesIn(Pageable pageable, @Param("projectName") String projectName, @Param("authorities") List authorities); - @Query("select subject from Subject subject left join fetch subject.sources " + @Query("select subject from Subject subject " + + "left join fetch subject.sources " + "WHERE subject.user.login = :login") Optional findOneWithEagerBySubjectLogin(@Param("login") String login); From 19519380a0eff3eb6646ed5e4cd84870d51b52fc Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 2 Mar 2023 15:56:41 +0100 Subject: [PATCH 011/120] CachedValue documentation and simplification --- .../kotlin/coroutines/CachedValue.kt | 80 +++++++++---------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt index a35220283..1336d5831 100644 --- a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt @@ -3,7 +3,6 @@ package org.radarbase.kotlin.coroutines import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Semaphore -import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicReference import kotlin.time.Duration import kotlin.time.ExperimentalTime @@ -31,12 +30,12 @@ open class CachedValue( /** * Query the cached value by running [transform] and return its result if valid. If - * [evaluate] returns false on the result, the cache computation is reevaluated if + * [evaluateValid] returns false on the result, the cache computation is reevaluated if * [CacheConfig.retryDuration] has been reached. */ suspend fun query( transform: suspend (T) -> R, - evaluate: (R) -> Boolean = { true }, + evaluateValid: (R) -> Boolean = { true }, ): CacheResult { val deferredResult = raceForDeferred() val deferred = deferredResult.value @@ -49,15 +48,24 @@ open class CachedValue( if (concurrentResult != null) { CacheMiss(transform(concurrentResult)) } else { - deferred.awaitCache(transform, evaluate) + deferred.awaitCache(transform, evaluateValid) } } } - suspend fun isStale(): Boolean { + /** + * Whether the contained value is stale. + * If [duration] is provided, it is considered stale only if the value is older than [duration]. + */ + suspend fun isStale(duration: Duration? = null): Boolean { val currentDeferred = cache.get() ?: return true if (!currentDeferred.isCompleted) return false - return currentDeferred.await().isExpired { true } + val result = currentDeferred.await() + return if (duration == null) { + result.isExpired() + } else { + result.isExpired(duration) + } } /** @@ -72,12 +80,12 @@ open class CachedValue( /** * Get cached value. If the cache is expired, fetch it again. The first coroutine context * that reaches this method will call [computeAndCache], others coroutine contexts will use the - * value computed by the first. If the value was retrieved from cache and [evaluate] + * value computed by the first. If the value was retrieved from cache and [evaluateValid] * returns false for that value, the result is recomputed. The result is not computed more * often than [CacheConfig.retryDuration]. If the result was an exception, the exception is * rethrown from cache. It is recomputed if the [CacheConfig.exceptionCacheDuration] has passed. */ - suspend inline fun get(noinline evaluate: (T) -> Boolean): CacheResult = query({ it }, evaluate) + suspend inline fun get(noinline evaluateValid: (T) -> Boolean): CacheResult = query({ it }, evaluateValid) /** * Test the cached value by running [predicate] and return its result if true. If @@ -114,10 +122,10 @@ open class CachedValue( private suspend fun DeferredCache.awaitCache( transform: suspend (T) -> R, - evaluate: (R) -> Boolean, + evaluateValid: (R) -> Boolean, ): CacheResult { val result = await().map(transform) - return if (result.isExpired(evaluate)) { + return if (result.isExpired(evaluateValid)) { // Either no new coroutine context had updated the cache value, then update it to // null. Otherwise, another suspend context is active and get() will await the // result from that context @@ -151,43 +159,31 @@ open class CachedValue( return result } - private inline fun CacheContents.isExpired(evaluate: (R) -> Boolean): Boolean = when { - this is CacheError -> exception is CancellationException || - isExpired(config.exceptionCacheDuration) - this is CacheValue && !evaluate(value) -> isExpired(config.retryDuration) - else -> isExpired(config.refreshDuration) + private inline fun CacheContents.isExpired( + evaluateValid: (R) -> Boolean = { true } + ): Boolean = if (this is CacheError) { + isExpired(config.exceptionCacheDuration) + } else { + this as CacheValue + isExpired(config.refreshDuration) || + (!evaluateValid(value) && isExpired(config.retryDuration)) } + /** + * Remove value from cache. Note that this does not cancel existing computations for the + * value, but the computed value will then not be stored. + */ fun clear() { cache.set(null) } @OptIn(ExperimentalTime::class) internal sealed class CacheContents( - initialDuration: Duration? = null, time: TimeMark? = null, ) { - protected val time: TimeMark - - init { - var now = time ?: TimeSource.Monotonic.markNow() - if (initialDuration != null) { - now -= initialDuration - } - this.time = now - } + protected val time: TimeMark = time ?: TimeSource.Monotonic.markNow() - @Volatile - private var isExpired = false - - fun isExpired(age: Duration): Boolean = when { - isExpired -> true - (time + age).hasPassedNow() -> { - isExpired = true - true - } - else -> false - } + open fun isExpired(age: Duration): Boolean = (time + age).hasPassedNow() abstract fun getOrThrow(): T @@ -199,6 +195,7 @@ open class CachedValue( internal class CacheError( val exception: Throwable, ) : CacheContents() { + override fun isExpired(age: Duration): Boolean = exception is CancellationException || super.isExpired(age) override fun getOrThrow(): T = throw exception @Suppress("UNCHECKED_CAST") override suspend fun map(transform: suspend (T) -> R): CacheContents = this as CacheError @@ -207,9 +204,8 @@ open class CachedValue( @OptIn(ExperimentalTime::class) internal class CacheValue( val value: T, - initialDuration: Duration? = null, time: TimeMark? = null, - ) : CacheContents(initialDuration, time) { + ) : CacheContents(time) { override fun getOrThrow(): T = value override suspend fun map(transform: suspend (T) -> R): CacheContents = try { @@ -219,14 +215,14 @@ open class CachedValue( } } + /** Result from cache of type [T]. */ sealed interface CacheResult { val value: T } + /** Cache hit, meaning the value was computed by another coroutine. */ data class CacheHit(override val value: T) : CacheResult - data class CacheMiss(override val value: T) : CacheResult - companion object { - private val logger = LoggerFactory.getLogger(CachedValue::class.java) - } + /** Cache miss, meaning the value was computed by the current coroutine. */ + data class CacheMiss(override val value: T) : CacheResult } From d69ad3cb1a9fae07c7f0a12f1f77946cf756ee7a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 2 Mar 2023 15:59:25 +0100 Subject: [PATCH 012/120] Do not consider empty cache stale --- .../kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt index 1336d5831..cc507fac8 100644 --- a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt +++ b/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt @@ -56,10 +56,13 @@ open class CachedValue( /** * Whether the contained value is stale. * If [duration] is provided, it is considered stale only if the value is older than [duration]. + * If no value is cache, it is not considered stale. */ suspend fun isStale(duration: Duration? = null): Boolean { - val currentDeferred = cache.get() ?: return true - if (!currentDeferred.isCompleted) return false + val currentDeferred = cache.get() + if (currentDeferred == null || !currentDeferred.isCompleted) { + return false + } val result = currentDeferred.await() return if (duration == null) { result.isExpired() From d42d051a981a183445c09e324b32c1485f0874a6 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 2 Mar 2023 16:19:34 +0100 Subject: [PATCH 013/120] Renamed kotlin-util -> radar-kotlin --- radar-auth/build.gradle | 2 +- {kotlin-util => radar-kotlin}/.gitignore | 0 {kotlin-util => radar-kotlin}/build.gradle | 1 - .../main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt | 0 .../main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt | 0 .../main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt | 0 .../main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt | 0 .../main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt | 0 .../kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt | 0 .../kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt | 0 settings.gradle | 2 +- 11 files changed, 2 insertions(+), 3 deletions(-) rename {kotlin-util => radar-kotlin}/.gitignore (100%) rename {kotlin-util => radar-kotlin}/build.gradle (99%) rename {kotlin-util => radar-kotlin}/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt (100%) rename {kotlin-util => radar-kotlin}/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt (100%) rename {kotlin-util => radar-kotlin}/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt (100%) rename {kotlin-util => radar-kotlin}/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt (100%) rename {kotlin-util => radar-kotlin}/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt (100%) rename {kotlin-util => radar-kotlin}/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt (100%) rename {kotlin-util => radar-kotlin}/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt (100%) diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index 3617ae599..272572c41 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -19,7 +19,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) api("org.jetbrains.kotlinx:kotlinx-coroutines-core") - implementation(project(":kotlin-util")) + implementation(project(":radar-kotlin")) implementation(platform('io.ktor:ktor-bom:2.2.3')) implementation("io.ktor:ktor-client-core") diff --git a/kotlin-util/.gitignore b/radar-kotlin/.gitignore similarity index 100% rename from kotlin-util/.gitignore rename to radar-kotlin/.gitignore diff --git a/kotlin-util/build.gradle b/radar-kotlin/build.gradle similarity index 99% rename from kotlin-util/build.gradle rename to radar-kotlin/build.gradle index 5e3e66be6..9dcd209c7 100644 --- a/kotlin-util/build.gradle +++ b/radar-kotlin/build.gradle @@ -27,7 +27,6 @@ dependencies { testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version } - tasks.withType(KotlinCompile).configureEach { compilerOptions { jvmTarget = JvmTarget.JVM_11 diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt similarity index 100% rename from kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt rename to radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt similarity index 100% rename from kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt rename to radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt similarity index 100% rename from kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt rename to radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt similarity index 100% rename from kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt rename to radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt diff --git a/kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt similarity index 100% rename from kotlin-util/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt rename to radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt diff --git a/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt b/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt similarity index 100% rename from kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt rename to radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt diff --git a/kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt b/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt similarity index 100% rename from kotlin-util/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt rename to radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt diff --git a/settings.gradle b/settings.gradle index b590d9a59..be988c69b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,4 @@ rootProject.name = 'management-portal' include ':oauth-client-util' include ':radar-auth' include ':managementportal-client' -include ':kotlin-util' +include ':radar-kotlin' From c224fdc26d21f2a9314f57a60bfbb293a9922e16 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 14 Mar 2023 15:45:56 +0100 Subject: [PATCH 014/120] Bump dev version --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 78bd4e811..0c79de787 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ apply plugin: 'io.spring.dependency-management' allprojects { group 'org.radarbase' - version '2.0.0' // project version + version '2.0.1-SNAPSHOT' // project version // The comment on the previous line is only there to identify the project version line easily // with a sed command, to auto-update the version number with the prepare-release-branch.sh From 3df0a06aa51f43b6c6b2e5a14d476dde4794a3c5 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 13:18:19 +0100 Subject: [PATCH 015/120] Moved radar-kotlin to radar-commons/radar-commons-kotlin --- build.gradle | 1 + managementportal-client/build.gradle | 4 +- .../management/auth/AuthTokenHolder.kt | 51 ---- .../auth/ClientCredentialsConfig.kt | 24 -- .../management/auth/MPOAuth2AccessToken.kt | 22 -- .../management/auth/OAuthClientProvider.kt | 179 -------------- .../radarbase/management/client/MPClient.kt | 10 +- .../management/client/MPClientTest.kt | 13 +- radar-auth/build.gradle | 2 +- radar-kotlin/.gitignore | 2 - radar-kotlin/build.gradle | 54 ---- .../kotlin/coroutines/CacheConfig.kt | 26 -- .../radarbase/kotlin/coroutines/CachedMap.kt | 67 ----- .../radarbase/kotlin/coroutines/CachedSet.kt | 46 ---- .../kotlin/coroutines/CachedValue.kt | 231 ------------------ .../radarbase/kotlin/coroutines/Extensions.kt | 136 ----------- .../kotlin/coroutines/CachedValueTest.kt | 179 -------------- .../kotlin/coroutines/ExtensionsKtTest.kt | 88 ------- settings.gradle | 1 - .../management/repository/UserRepository.java | 2 + .../management/service/AuditEventService.java | 13 +- .../management/web/rest/AuditResource.java | 11 +- .../web/rest/AuditResourceIntTest.java | 12 +- 23 files changed, 37 insertions(+), 1137 deletions(-) delete mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt delete mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt delete mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt delete mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt delete mode 100644 radar-kotlin/.gitignore delete mode 100644 radar-kotlin/build.gradle delete mode 100644 radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt delete mode 100644 radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt delete mode 100644 radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt delete mode 100644 radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt delete mode 100644 radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt delete mode 100644 radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt delete mode 100644 radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt diff --git a/build.gradle b/build.gradle index 4ff60645b..0224c5019 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,7 @@ allprojects { repositories { mavenCentral() + maven { url = "https://oss.sonatype.org/content/repositories/snapshots" } } idea { diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index ed6ed9385..11fe25c33 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -24,10 +24,12 @@ description = "Kotlin ManagementPortal client" dependencies { api("org.jetbrains.kotlin:kotlin-stdlib:1.8.10") - api(platform('io.ktor:ktor-bom:2.2.3')) implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.10") + implementation("org.radarbase:radar-commons-kotlin:0.16.0-SNAPSHOT") api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) + api(platform('io.ktor:ktor-bom:2.2.3')) + api("io.ktor:ktor-client-core") api("io.ktor:ktor-client-auth") implementation("io.ktor:ktor-client-cio") diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt deleted file mode 100644 index 38a720cc8..000000000 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/AuthTokenHolder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.radarbase.management.auth - -import kotlinx.coroutines.CompletableDeferred -import java.util.concurrent.atomic.AtomicReference - -internal class AuthTokenHolder( - private val loadTokens: suspend () -> T? -) { - private val refreshTokensDeferred = AtomicReference?>(null) - private val loadTokensDeferred = AtomicReference?>(null) - - internal fun clearToken() { - loadTokensDeferred.set(null) - refreshTokensDeferred.set(null) - } - - internal suspend fun loadToken(): T? { - var deferred: CompletableDeferred? - do { - deferred = loadTokensDeferred.get() - val newValue = deferred ?: CompletableDeferred() - } while (!loadTokensDeferred.compareAndSet(deferred, newValue)) - - return if (deferred != null) { - deferred.await() - } else { - val newTokens = loadTokens() - loadTokensDeferred.get()!!.complete(newTokens) - newTokens - } - } - - internal suspend fun setToken(block: suspend () -> T?): T? { - var deferred: CompletableDeferred? - do { - deferred = refreshTokensDeferred.get() - val newValue = deferred ?: CompletableDeferred() - } while (!refreshTokensDeferred.compareAndSet(deferred, newValue)) - - val newToken = if (deferred == null) { - val newTokens = block() - refreshTokensDeferred.get()!!.complete(newTokens) - refreshTokensDeferred.set(null) - newTokens - } else { - deferred.await() - } - loadTokensDeferred.set(CompletableDeferred(newToken)) - return newToken - } -} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt deleted file mode 100644 index c063908ff..000000000 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/ClientCredentialsConfig.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.radarbase.management.auth - -data class ClientCredentialsConfig( - val tokenUrl: String, - val clientId: String? = null, - val clientSecret: String? = null, -) { - /** - * Fill in the client ID and client secret from environment variables. The variables are - * `<prefix>_CLIENT_ID` and `<prefix>_CLIENT_SECRET`. - */ - fun copyWithEnv(prefix: String = "MANAGEMENT_PORTAL"): ClientCredentialsConfig { - var result = this - val envClientId = System.getenv("${prefix}_CLIENT_ID") - if (envClientId != null) { - result = result.copy(clientId = envClientId) - } - val envClientSecret = System.getenv("${prefix}_CLIENT_SECRET") - if (envClientSecret != null) { - result = result.copy(clientSecret = envClientSecret) - } - return result - } -} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt deleted file mode 100644 index bcdc961f1..000000000 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/MPOAuth2AccessToken.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2021. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.management.auth - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class MPOAuth2AccessToken( - @SerialName("access_token") val accessToken: String? = null, - @SerialName("refresh_token") val refreshToken: String? = null, - @SerialName("expires_in") val expiresIn: Long = 0, - @SerialName("token_type") val tokenType: String? = null, - @SerialName("user_id") val externalUserId: String? = null, -) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt deleted file mode 100644 index f3251b434..000000000 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/auth/OAuthClientProvider.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.radarbase.management.auth - -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.plugins.auth.* -import io.ktor.client.plugins.auth.providers.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.client.request.forms.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.http.auth.* -import io.ktor.serialization.kotlinx.json.* -import io.ktor.util.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import org.slf4j.LoggerFactory - -private val logger = LoggerFactory.getLogger(Auth::class.java) - -/** - * Installs the client's [BearerAuthProvider]. - */ -fun Auth.clientCredentials(block: ClientCredentialsAuthConfig.() -> Unit) { - with(ClientCredentialsAuthConfig().apply(block)) { - this@clientCredentials.providers.add(ClientCredentialsAuthProvider(_requestToken, _loadTokens, _sendWithoutRequest, realm)) - } -} - -fun Auth.clientCredentials( - authConfig: ClientCredentialsConfig, - targetHost: String? = null, -): Flow { - requireNotNull(authConfig.clientId) { "Missing client ID" } - requireNotNull(authConfig.clientSecret) { "Missing client secret"} - val flow = MutableStateFlow(null) - - clientCredentials { - if (targetHost != null) { - sendWithoutRequest { request -> - request.url.host == targetHost - } - } - requestToken { - val response = client.submitForm( - url = authConfig.tokenUrl, - formParameters = Parameters.build { - append("grant_type", "client_credentials") - append("client_id", authConfig.clientId) - append("client_secret", authConfig.clientSecret) - } - ) { - accept(ContentType.Application.Json) - markAsRequestTokenRequest() - } - val refreshTokenInfo: MPOAuth2AccessToken? = if (!response.status.isSuccess()) { - logger.error("Failed to fetch new token: {}", response.bodyAsText()) - null - } else { - response.body() - } - flow.value = refreshTokenInfo - refreshTokenInfo - } - } - - return flow -} - -/** - * Parameters to be passed to [BearerAuthConfig.refreshTokens] lambda. - */ -class RequestTokenParams( - val client: HttpClient, -) { - /** - * Marks that this request is for requesting auth tokens, resulting in a special handling of it. - */ - fun HttpRequestBuilder.markAsRequestTokenRequest() { - attributes.put(Auth.AuthCircuitBreaker, Unit) - } -} - -/** - * A configuration for [BearerAuthProvider]. - */ -@KtorDsl -class ClientCredentialsAuthConfig { - internal var _requestToken: suspend RequestTokenParams.() -> MPOAuth2AccessToken? = { null } - internal var _loadTokens: suspend () -> MPOAuth2AccessToken? = { null } - internal var _sendWithoutRequest: (HttpRequestBuilder) -> Boolean = { true } - - var realm: String? = null - - /** - * Configures a callback that refreshes a token when the 401 status code is received. - */ - fun requestToken(block: suspend RequestTokenParams.() -> MPOAuth2AccessToken?) { - _requestToken = block - } - - /** - * Configures a callback that loads a cached token from a local storage. - * Note: Using the same client instance here to make a request will result in a deadlock. - */ - fun loadTokens(block: suspend () -> MPOAuth2AccessToken?) { - _loadTokens = block - } - - /** - * Sends credentials without waiting for [HttpStatusCode.Unauthorized]. - */ - fun sendWithoutRequest(block: (HttpRequestBuilder) -> Boolean) { - _sendWithoutRequest = block - } -} - -/** - * An authentication provider for the Bearer HTTP authentication scheme. - * Bearer authentication involves security tokens called bearer tokens. - * As an example, these tokens can be used as a part of OAuth flow to authorize users of your application - * by using external providers, such as Google, Facebook, Twitter, and so on. - * - * You can learn more from [Bearer authentication](https://ktor.io/docs/bearer-client.html). - */ -class ClientCredentialsAuthProvider( - private val requestToken: suspend RequestTokenParams.() -> MPOAuth2AccessToken?, - loadTokens: suspend () -> MPOAuth2AccessToken?, - private val sendWithoutRequestCallback: (HttpRequestBuilder) -> Boolean = { true }, - private val realm: String?, -) : AuthProvider { - - @Suppress("OverridingDeprecatedMember") - @Deprecated("Please use sendWithoutRequest function instead", replaceWith = ReplaceWith("sendWithoutRequest(request)")) - override val sendWithoutRequest: Boolean - get() = error("Deprecated") - - private val tokensHolder = AuthTokenHolder(loadTokens) - - override fun sendWithoutRequest(request: HttpRequestBuilder): Boolean = sendWithoutRequestCallback(request) - - /** - * Checks if current provider is applicable to the request. - */ - override fun isApplicable(auth: HttpAuthHeader): Boolean { - if (auth.authScheme != AuthScheme.Bearer) return false - if (realm == null) return true - if (auth !is HttpAuthHeader.Parameterized) return false - - return auth.parameter("realm") == realm - } - - /** - * Adds an authentication method headers and credentials. - */ - override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?) { - val token = tokensHolder.loadToken() ?: return - - request.headers { - if (contains(HttpHeaders.Authorization)) { - remove(HttpHeaders.Authorization) - } - append(HttpHeaders.Authorization, "Bearer ${token.accessToken}") - } - } - - override suspend fun refreshToken(response: HttpResponse): Boolean { - val newToken = tokensHolder.setToken { - requestToken(RequestTokenParams(response.call.client)) - } - return newToken != null - } - - fun clearToken() { - tokensHolder.clearToken() - } -} diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index 9dd0573f4..91d61d3f0 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import org.radarbase.management.auth.MPOAuth2AccessToken +import org.radarbase.ktor.auth.OAuth2AccessToken import java.io.IOException import java.time.Duration import java.util.* @@ -38,7 +38,7 @@ fun mpClient(config: MPClient.Config.() -> Unit): MPClient { */ @Suppress("unused", "MemberVisibilityCanBePrivate") class MPClient(config: Config) { - lateinit var token: Flow + lateinit var token: Flow private val url: String = requireNotNull(config.url) { "Missing server URL" @@ -46,7 +46,7 @@ class MPClient(config: Config) { /** HTTP client to make requests with. */ private val originalHttpClient: HttpClient? = config.httpClient - private val auth: Auth.() -> Flow = config.auth + private val auth: Auth.() -> Flow = config.auth val httpClient = (originalHttpClient ?: HttpClient(CIO)).config { install(HttpTimeout) { @@ -147,14 +147,14 @@ class MPClient(config: Config) { } class Config { - internal var auth: Auth.() -> Flow = { MutableStateFlow(null) } + internal var auth: Auth.() -> Flow = { MutableStateFlow(null) } /** HTTP client to make requests with. */ var httpClient: HttpClient? = null var url: String? = null - fun auth(install: Auth.() -> Flow) { + fun auth(install: Auth.() -> Flow) { auth = install } diff --git a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt index a306831ab..0ba119f0d 100644 --- a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt +++ b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt @@ -9,10 +9,8 @@ package org.radarbase.management.client -import com.fasterxml.jackson.databind.deser.DataFormatReaders.Match import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* -import com.github.tomakehurst.wiremock.matching.ContentPattern import com.github.tomakehurst.wiremock.matching.EqualToPattern import com.github.tomakehurst.wiremock.stubbing.StubMapping import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -27,9 +25,9 @@ import org.hamcrest.Matchers.hasSize import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.radarbase.management.auth.ClientCredentialsConfig -import org.radarbase.management.auth.MPOAuth2AccessToken -import org.radarbase.management.auth.clientCredentials +import org.radarbase.ktor.auth.ClientCredentialsConfig +import org.radarbase.ktor.auth.OAuth2AccessToken +import org.radarbase.ktor.auth.clientCredentials import org.slf4j.LoggerFactory import java.net.HttpURLConnection.HTTP_OK import java.net.HttpURLConnection.HTTP_UNAUTHORIZED @@ -157,12 +155,13 @@ class MPClientTest { coerceInputValues = true } - val token = json.decodeFromString("""{"access_token":"access token","token_type":"bearer","expires_in":899,"scope":"PROJECT.READ","iss":"ManagementPortal","grant_type":"client_credentials","iat":1600000000,"jti":"some token"}""") + val token = json.decodeFromString("""{"access_token":"access token","token_type":"bearer","expires_in":899,"scope":"PROJECT.READ","iss":"ManagementPortal","grant_type":"client_credentials","iat":1600000000,"jti":"some token"}""") assertThat(token, Matchers.equalTo( - MPOAuth2AccessToken( + OAuth2AccessToken( accessToken = "access token", expiresIn = 899, tokenType = "bearer", + scope = "PROJECT.READ" ) )) } diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index 272572c41..2d0a448c9 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -19,7 +19,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) api("org.jetbrains.kotlinx:kotlinx-coroutines-core") - implementation(project(":radar-kotlin")) + implementation("org.radarbase:radar-commons-kotlin:0.16.0-SNAPSHOT") implementation(platform('io.ktor:ktor-bom:2.2.3')) implementation("io.ktor:ktor-client-core") diff --git a/radar-kotlin/.gitignore b/radar-kotlin/.gitignore deleted file mode 100644 index 3c0160d04..000000000 --- a/radar-kotlin/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -out/ diff --git a/radar-kotlin/build.gradle b/radar-kotlin/build.gradle deleted file mode 100644 index 9dcd209c7..000000000 --- a/radar-kotlin/build.gradle +++ /dev/null @@ -1,54 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - id 'maven-publish' - id 'org.jetbrains.kotlin.jvm' - id 'org.jetbrains.dokka' -} - - -sourceCompatibility = JavaVersion.VERSION_11 -targetCompatibility = JavaVersion.VERSION_11 - -description = 'Library for Kotlin utility classes and functions' - - -dependencies { - implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) - api("org.jetbrains.kotlinx:kotlinx-coroutines-core") - - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version - testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '2.2' - - testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: logback_version - testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version -} - -tasks.withType(KotlinCompile).configureEach { - compilerOptions { - jvmTarget = JvmTarget.JVM_11 - apiVersion = KotlinVersion.KOTLIN_1_8 - languageVersion = KotlinVersion.KOTLIN_1_8 - } -} - -test { - testLogging { - exceptionFormat = 'full' - } - useJUnitPlatform() -} - -tasks.register('ghPagesJavadoc', Copy) { - from file("$buildDir/dokka/javadoc") - into file("$rootDir/public/radar-auth-javadoc") - dependsOn(dokkaJavadoc) -} - -ext.projectLanguage = "kotlin" - -apply from: "$rootDir/gradle/style.gradle" -apply from: "$rootDir/gradle/publishing.gradle" diff --git a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt deleted file mode 100644 index 59a6aa3b5..000000000 --- a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CacheConfig.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.radarbase.kotlin.coroutines - -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -data class CacheConfig( - /** Duration after which the cache is considered stale and should be refreshed. */ - val refreshDuration: Duration = 30.minutes, - /** Duration after which the cache may be refreshed if the cache does not fulfill a certain - * requirement. This should be shorter than [refreshDuration] to have effect. */ - val retryDuration: Duration = 1.minutes, - /** Time until the result may be recomputed when an exception is set for the cache. */ - val exceptionCacheDuration: Duration = 10.seconds, - /** - * Number of simultaneous computations that may occur. Increase if the time to computation - * is very variable. - */ - val maxSimultaneousCompute: Int = 1, -) { - init { - require(retryDuration > Duration.ZERO) { "Cache fetch duration $retryDuration must be positive" } - require(refreshDuration >= retryDuration) { "Cache maximum age $refreshDuration must be at least fetch timeout $retryDuration" } - require(maxSimultaneousCompute > 0) { "At least one context must be able to compute the result" } - } -} diff --git a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt deleted file mode 100644 index 524025151..000000000 --- a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedMap.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 The Hyve - * - * 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 - * - * http://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.radarbase.kotlin.coroutines - -/** Set of data that is cached for a duration of time. */ -class CachedMap( - cacheConfig: CacheConfig = CacheConfig(), - supplier: suspend () -> Map, -): CachedValue>(cacheConfig, supplier) { - /** Whether the cache contains [key]. If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. */ - suspend fun contains(key: K): Boolean = test { key in it } - - /** - * Find a pair matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - suspend fun find(predicate: (K, V) -> Boolean): Pair? = query( - { map -> - map.entries - .find { (k, v) -> predicate(k, v) } - ?.toPair() - }, - { it != null }, - ).value - - /** - * Find a pair matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - suspend fun findValue(predicate: (V) -> Boolean): V? = query( - { map -> map.values.find { predicate(it) } }, - { it != null }, - ).value - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - override suspend fun get(): Map = get { it.isNotEmpty() }.value - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - suspend fun get(key: K): V? = query({ it[key] }, { it != null }).value -} diff --git a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt deleted file mode 100644 index 361bd89dc..000000000 --- a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedSet.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Hyve - * - * 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 - * - * http://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.radarbase.kotlin.coroutines - -/** - * Set of data that is cached for a duration of time. - * - * @param supplier How to update the cache. - */ -class CachedSet( - cacheConfig: CacheConfig = CacheConfig(), - supplier: suspend () -> Set, -): CachedValue>(cacheConfig, supplier) { - /** Whether the cache contains [value]. If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. */ - suspend fun contains(value: T): Boolean = test { value in it } - - /** - * Find a value matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - suspend fun find(predicate: (T) -> Boolean): T? = query({ it.find(predicate) }, { it != null }).value - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - override suspend fun get(): Set = get { it.isNotEmpty() }.value -} diff --git a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt deleted file mode 100644 index cc507fac8..000000000 --- a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/CachedValue.kt +++ /dev/null @@ -1,231 +0,0 @@ -package org.radarbase.kotlin.coroutines - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.sync.Semaphore -import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlin.time.TimeMark -import kotlin.time.TimeSource - -internal typealias DeferredCache = CompletableDeferred> - -/** - * Caches a value with full support for coroutines. The value that will be cached is computed by - * [supplier]. - * Only one coroutine context will compute the value at a time, other coroutine contexts will wait - * for it to finish. - */ -open class CachedValue( - private val config: CacheConfig, - private val supplier: suspend () -> T, -) { - private val cache = AtomicReference>>() - private val semaphore: Semaphore? = if (config.maxSimultaneousCompute > 1) { - Semaphore(config.maxSimultaneousCompute - 1) - } else { - null - } - - /** - * Query the cached value by running [transform] and return its result if valid. If - * [evaluateValid] returns false on the result, the cache computation is reevaluated if - * [CacheConfig.retryDuration] has been reached. - */ - suspend fun query( - transform: suspend (T) -> R, - evaluateValid: (R) -> Boolean = { true }, - ): CacheResult { - val deferredResult = raceForDeferred() - val deferred = deferredResult.value - - return if (deferredResult is CacheMiss) { - val result = deferred.computeAndCache() - CacheMiss(transform(result)) - } else { - val concurrentResult = deferred.concurrentComputeAndCache() - if (concurrentResult != null) { - CacheMiss(transform(concurrentResult)) - } else { - deferred.awaitCache(transform, evaluateValid) - } - } - } - - /** - * Whether the contained value is stale. - * If [duration] is provided, it is considered stale only if the value is older than [duration]. - * If no value is cache, it is not considered stale. - */ - suspend fun isStale(duration: Duration? = null): Boolean { - val currentDeferred = cache.get() - if (currentDeferred == null || !currentDeferred.isCompleted) { - return false - } - val result = currentDeferred.await() - return if (duration == null) { - result.isExpired() - } else { - result.isExpired(duration) - } - } - - /** - * Get cached value. If the cache is expired, fetch it again. The first coroutine context - * that reaches this method will call [computeAndCache], others coroutine contexts will use the - * value computed by the first. The result is not computed more - * often than [CacheConfig.retryDuration]. If the result was an exception, the exception is - * rethrown from cache. It is recomputed if the [CacheConfig.exceptionCacheDuration] has passed. - */ - open suspend fun get(): T = query({ it }) { false }.value - - /** - * Get cached value. If the cache is expired, fetch it again. The first coroutine context - * that reaches this method will call [computeAndCache], others coroutine contexts will use the - * value computed by the first. If the value was retrieved from cache and [evaluateValid] - * returns false for that value, the result is recomputed. The result is not computed more - * often than [CacheConfig.retryDuration]. If the result was an exception, the exception is - * rethrown from cache. It is recomputed if the [CacheConfig.exceptionCacheDuration] has passed. - */ - suspend inline fun get(noinline evaluateValid: (T) -> Boolean): CacheResult = query({ it }, evaluateValid) - - /** - * Test the cached value by running [predicate] and return its result if true. If - * [predicate] returns false on the result, the cache computation is reevaluated if - * [CacheConfig.retryDuration] has been reached. - */ - suspend inline fun test(noinline predicate: (T) -> Boolean): Boolean { - return query(predicate) { it }.value - } - - private suspend fun DeferredCache.computeAndCache(): T { - val result = try { - val value = supplier() - complete(CacheValue(value)) - value - } catch (ex: Throwable) { - complete(CacheError(ex)) - throw ex - } - return result - } - - private suspend fun DeferredCache.concurrentComputeAndCache(): T? { - if (isCompleted) return null - - return semaphore?.tryWithPermitOrNull { - if (isCompleted) { - null - } else { - computeAndCache() - } - } - } - - private suspend fun DeferredCache.awaitCache( - transform: suspend (T) -> R, - evaluateValid: (R) -> Boolean, - ): CacheResult { - val result = await().map(transform) - return if (result.isExpired(evaluateValid)) { - // Either no new coroutine context had updated the cache value, then update it to - // null. Otherwise, another suspend context is active and get() will await the - // result from that context - cache.compareAndSet(this, null) - query(transform) { false } - } else { - val value = result.getOrThrow() - CacheHit(value) - } - } - - /** - * Race for the first suspend context to create a CompletableDeferred object. All other contexts - * will use that context to read their values. - * - * @return a pair of a CompletableDeferred value and a boolean, if true this context is the - * winner, if false this should use the deferred to read its value. - */ - private fun raceForDeferred(): CacheResult> { - var result: CacheResult> - - do { - val previousDeferred = cache.get() - result = if (previousDeferred == null) { - CacheMiss(CompletableDeferred()) - } else { - CacheHit(previousDeferred) - } - } while (!cache.compareAndSet(previousDeferred, result.value)) - - return result - } - - private inline fun CacheContents.isExpired( - evaluateValid: (R) -> Boolean = { true } - ): Boolean = if (this is CacheError) { - isExpired(config.exceptionCacheDuration) - } else { - this as CacheValue - isExpired(config.refreshDuration) || - (!evaluateValid(value) && isExpired(config.retryDuration)) - } - - /** - * Remove value from cache. Note that this does not cancel existing computations for the - * value, but the computed value will then not be stored. - */ - fun clear() { - cache.set(null) - } - - @OptIn(ExperimentalTime::class) - internal sealed class CacheContents( - time: TimeMark? = null, - ) { - protected val time: TimeMark = time ?: TimeSource.Monotonic.markNow() - - open fun isExpired(age: Duration): Boolean = (time + age).hasPassedNow() - - abstract fun getOrThrow(): T - - @Suppress("UNCHECKED_CAST") - abstract suspend fun map(transform: suspend (T) -> R): CacheContents - } - - @OptIn(ExperimentalTime::class) - internal class CacheError( - val exception: Throwable, - ) : CacheContents() { - override fun isExpired(age: Duration): Boolean = exception is CancellationException || super.isExpired(age) - override fun getOrThrow(): T = throw exception - @Suppress("UNCHECKED_CAST") - override suspend fun map(transform: suspend (T) -> R): CacheContents = this as CacheError - } - - @OptIn(ExperimentalTime::class) - internal class CacheValue( - val value: T, - time: TimeMark? = null, - ) : CacheContents(time) { - override fun getOrThrow(): T = value - - override suspend fun map(transform: suspend (T) -> R): CacheContents = try { - CacheValue(transform(value), time = time) - } catch (ex: Throwable) { - CacheError(ex) - } - } - - /** Result from cache of type [T]. */ - sealed interface CacheResult { - val value: T - } - - /** Cache hit, meaning the value was computed by another coroutine. */ - data class CacheHit(override val value: T) : CacheResult - - /** Cache miss, meaning the value was computed by the current coroutine. */ - data class CacheMiss(override val value: T) : CacheResult -} diff --git a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt b/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt deleted file mode 100644 index 87cbf27f7..000000000 --- a/radar-kotlin/src/main/kotlin/org/radarbase/kotlin/coroutines/Extensions.kt +++ /dev/null @@ -1,136 +0,0 @@ -@file:Suppress("unused") - -package org.radarbase.kotlin.coroutines - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.consume -import kotlinx.coroutines.sync.Semaphore -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.time.Duration - -/** - * Try to acquire a semaphore permit, and run [block] if successful. - * If this cannot be achieved without blocking, return null. - * @return result of [block] or null if no permit could be acquired. - */ -suspend fun Semaphore.tryWithPermitOrNull(block: suspend () -> T): T? { - if (!tryAcquire()) return null - return try { - block() - } finally { - release() - } -} - -/** - * Get a future value via coroutine suspension. - * The future is evaluated in context [Dispatchers.IO]. - */ -suspend fun Future.suspendGet( - duration: Duration? = null, -): T = coroutineScope { - val channel = Channel() - launch { - try { - channel.receive() - } catch (ex: CancellationException) { - cancel(true) - } - } - try { - withContext(Dispatchers.IO) { - if (duration != null) { - get(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS) - } else { - get() - } - } - } catch (ex: InterruptedException) { - throw CancellationException("Future was interrupted", ex) - } finally { - channel.send(Unit) - } -} - -/** - * Transform each value in the iterable in a separate coroutine and await termination. - */ -suspend inline fun Iterable.forkJoin( - coroutineContext: CoroutineContext = Dispatchers.Default, - crossinline transform: suspend CoroutineScope.(T) -> R -): List = coroutineScope { - map { t -> async(coroutineContext) { transform(t) } } - .awaitAll() -} - -/** - * Consume the first value produced by the producer on its provided channel. Once a value is sent - * by the producer, its coroutine is cancelled. - * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not - * produce any values. - */ -suspend inline fun consumeFirst( - coroutineContext: CoroutineContext = Dispatchers.Default, - crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit -): T = coroutineScope { - val channel = Channel() - - val producerJob = launch(coroutineContext) { - try { - producer(channel::send) - } finally { - channel.close() - } - } - - val result = channel.consume { receive() } - producerJob.cancel() - result -} - -/** - * Transforms each value with [transform] and returns the first value where [predicate] returns - * true. Each value is transformed and evaluated in its own async context. If no transformed value - * satisfies predicate, null is returned. - */ -suspend fun Iterable.forkFirstOfOrNull( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - transform: suspend CoroutineScope.(T) -> R, - predicate: suspend CoroutineScope.(R) -> Boolean, -): R? = consumeFirst(coroutineContext) { emit -> - forkJoin(coroutineContext) { t -> - val result = transform(t) - if (predicate(result)) { - emit(result) - } - } - emit(null) -} - -suspend fun Iterable.forkFirstOfNotNullOrNull( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - transform: suspend CoroutineScope.(T) -> R? -): R? = forkFirstOfOrNull(coroutineContext, transform) { it != null } - -/** - * Returns true as soon as [predicate] returns true on a value, or false if [predicate] does - * not return true on any of the values. All values are evaluated in a separate async context using - * [forkJoin]. - */ -suspend fun Iterable.forkAny( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - predicate: suspend CoroutineScope.(T) -> Boolean -): Boolean = forkFirstOfOrNull(coroutineContext, predicate) { it } ?: false - -operator fun Set.plus(elements: Set): Set = when { - isEmpty() -> elements - elements.isEmpty() -> this - else -> buildSet(size + elements.size) { - addAll(this) - addAll(elements) - } -} diff --git a/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt b/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt deleted file mode 100644 index f9d1400eb..000000000 --- a/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/CachedValueTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.radarbase.kotlin.coroutines - -import kotlinx.coroutines.* -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.slf4j.LoggerFactory -import java.time.Duration -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.ExperimentalTime -import kotlin.time.TimeMark -import kotlin.time.TimeSource - -@OptIn(ExperimentalTime::class, DelicateCoroutinesApi::class) -internal class CachedValueTest { - private lateinit var config: CacheConfig - - private val calls: AtomicInteger = AtomicInteger(0) - - @BeforeEach - fun setUp() { - calls.set(0) - config = CacheConfig( - refreshDuration = 20.milliseconds, - retryDuration = 10.milliseconds, - exceptionCacheDuration = 10.milliseconds - ) - } - - @Test - fun get() { - val cache = CachedValue(config) { calls.incrementAndGet() } - runBlocking(GlobalScope.coroutineContext) { - assertThat("Initial value should refresh", cache.get(), `is`(1)) - assertThat("No refresh within threshold", cache.get(), `is`(1)) - delay(10) - assertThat("Refresh after threshold", cache.get(), `is`(2)) - assertThat("No refresh after threshold", cache.get(), `is`(2)) - } - } - - @Test - fun getInvalid() { - val cache = CachedValue(config) { calls.incrementAndGet() } - runBlocking { - assertThat("Initial value should refresh", cache.get { it < 0 }, equalTo(CachedValue.CacheMiss(1))) - assertThat("No refresh within threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheHit(1))) - delay(10) - assertThat("Refresh after threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheMiss(2))) - assertThat("No refresh after threshold", cache.get { it < 0 }, equalTo(CachedValue.CacheHit(2))) - } - } - - @Test - fun getValid() { - val cache = CachedValue(config) { calls.incrementAndGet() } - runBlocking { - assertThat("Initial value should refresh", cache.get { it >= 0 }, equalTo(CachedValue.CacheMiss(1))) - assertThat("No refresh within threshold", cache.get { it >= 0 }, equalTo(CachedValue.CacheHit(1))) - delay(10) - assertThat("No refresh after valid value", cache.get { it >= 0 }, equalTo(CachedValue.CacheHit(1))) - } - } - - @Test - fun refresh() { - val cache = CachedValue(config) { calls.incrementAndGet() } - - runBlocking { - assertThat("Initial get calls supplier", cache.get(), `is`(1)) - assertThat("Next get uses cache", cache.get(), `is`(1)) - cache.clear() - assertThat("Next get uses cache", cache.get(), `is`(2)) - } - } - - @Test - fun query() { - val cache = CachedValue(config) { calls.incrementAndGet() } - - runBlocking { - assertThat("Initial value should refresh", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheMiss(2))) - assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheHit(2))) - delay(10) - assertThat( - "Retry because predicate does not match", - cache.query({ it + 1 }, { it > 2 }), - equalTo(CachedValue.CacheMiss(3)) - ) - assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), equalTo(CachedValue.CacheHit(3))) - delay(10) - assertThat( - "No retry because predicate matches", - cache.query({ it + 1 }, { it > 2 }), - equalTo(CachedValue.CacheHit(3)) - ) - delay(10) - assertThat( - "Refresh after refresh threshold since last retry", - cache.query({ it + 1 }, { it > 2 }), - equalTo(CachedValue.CacheMiss(4)) - ) - } - } - - - @Test - fun getMultithreaded() { - val cache = CachedValue(config) { - calls.incrementAndGet() - delay(50.milliseconds) - calls.get() - } - - runBlocking { - (0 .. 5) - .forkJoin { - cache.get() - } - .forEach { - assertThat("Get the same value in all contexts", it, `is`(1)) - } - } - - assertThat("No more calls are made", calls.get(), `is`(1)) - } - - @Test - fun getMulti2threaded() { - val cache = CachedValue(config.copy( - maxSimultaneousCompute = 2 - )) { - calls.incrementAndGet() - delay(50.milliseconds) - calls.get() - } - - runBlocking { - val values = (0 .. 5) - .forkJoin { - cache.get() - } - - assertThat(values[0], lessThan(3)) - values.forEach { - assertThat("Get the same value in all contexts", it, `is`(values[0])) - } - } - - assertThat("Two threads should be computing the value", calls.get(), `is`(2)) - } - - - @Test - fun throwTest() { - val cache = CachedValue(config.copy(refreshDuration = 20.milliseconds)) { - val newValue = calls.incrementAndGet() - if (newValue % 2 == 0) throw IllegalStateException() else newValue - } - - runBlocking { - assertThat(cache.get(), `is`(1)) - assertThat(cache.get(), `is`(1)) - delay(21.milliseconds) - assertThrows { cache.get() } - assertThrows { cache.get() } - delay(11.milliseconds) - assertThat(cache.get(), `is`(3)) - } - } - - companion object { - private val logger = LoggerFactory.getLogger(CachedValueTest::class.java) - } -} diff --git a/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt b/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt deleted file mode 100644 index 7cb9a18c2..000000000 --- a/radar-kotlin/src/test/kotlin/org/radarbase/kotlin/coroutines/ExtensionsKtTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.radarbase.kotlin.coroutines - -import kotlinx.coroutines.* -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.greaterThan -import org.hamcrest.Matchers.lessThan -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.fail -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.ExperimentalTime -import kotlin.time.measureTime - -@OptIn(ExperimentalTime::class) -class ExtensionsKtTest { - companion object { - @BeforeAll - @JvmStatic - fun setUpClass() { - runBlocking { - println("warmed up coroutines") - } - } - } - - @Test - fun testConsumeFirst() = runBlocking { - val inBlockingTime = measureTime { - val first = consumeFirst { emit -> - listOf( - async(Dispatchers.Default) { - delay(200.milliseconds) - emit("a") - fail("Should be cancelled") - }, - async(Dispatchers.Default) { - delay(50.milliseconds) - emit("b") - }, - ).awaitAll() - } - assertEquals("b", first) - } - assertThat(inBlockingTime, greaterThan(50.milliseconds)) - assertThat(inBlockingTime, lessThan(200.milliseconds)) - } - - @Test - fun testForkJoin() = runBlocking { - val inBlockingTime = measureTime { - val result = listOf(100.milliseconds, 50.milliseconds) - .forkJoin { - delay(it) - it - } - assertEquals(listOf(100.milliseconds, 50.milliseconds), result) - } - assertThat(inBlockingTime, greaterThan(100.milliseconds)) - } - - - @Test - fun testForkJoinFirst() = runBlocking { - val inBlockingTime = measureTime { - val result: Duration? = consumeFirst { emit -> - listOf(200.milliseconds, 50.milliseconds) - .forkJoin { - delay(it) - emit(it) - } - emit(null) - } - assertEquals(50.milliseconds, result) - } - assertThat(inBlockingTime, lessThan(200.milliseconds)) - assertThat(inBlockingTime, greaterThan(50.milliseconds)) - } - - @Test - fun testConcurrentAny() { - runBlocking { - assertTrue(listOf(1, 2, 3, 4).forkAny { it > 3 }) - assertFalse(listOf(1, 2, 3, 4).forkAny { it < 1 }) - } - } -} diff --git a/settings.gradle b/settings.gradle index be988c69b..bb323b41a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,3 @@ rootProject.name = 'management-portal' include ':oauth-client-util' include ':radar-auth' include ':managementportal-client' -include ':radar-kotlin' diff --git a/src/main/java/org/radarbase/management/repository/UserRepository.java b/src/main/java/org/radarbase/management/repository/UserRepository.java index 8c57b8f65..48b2066ea 100644 --- a/src/main/java/org/radarbase/management/repository/UserRepository.java +++ b/src/main/java/org/radarbase/management/repository/UserRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.repository.RepositoryDefinition; import org.springframework.data.repository.history.RevisionRepository; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Component; import java.util.List; import java.util.Optional; @@ -18,6 +19,7 @@ * Spring Data JPA repository for the User entity. */ @RepositoryDefinition(domainClass = User.class, idClass = Long.class) +@Component public interface UserRepository extends JpaRepository, RevisionRepository, JpaSpecificationExecutor { diff --git a/src/main/java/org/radarbase/management/service/AuditEventService.java b/src/main/java/org/radarbase/management/service/AuditEventService.java index 11eaafd31..7f7c36b2a 100644 --- a/src/main/java/org/radarbase/management/service/AuditEventService.java +++ b/src/main/java/org/radarbase/management/service/AuditEventService.java @@ -19,11 +19,14 @@ @Transactional public class AuditEventService { - @Autowired - private PersistenceAuditEventRepository persistenceAuditEventRepository; + private final PersistenceAuditEventRepository persistenceAuditEventRepository; - @Autowired - private AuditEventConverter auditEventConverter; + private final AuditEventConverter auditEventConverter; + + public AuditEventService(PersistenceAuditEventRepository persistenceAuditEventRepository, AuditEventConverter auditEventConverter) { + this.persistenceAuditEventRepository = persistenceAuditEventRepository; + this.auditEventConverter = auditEventConverter; + } public Page findAll(Pageable pageable) { return persistenceAuditEventRepository.findAll(pageable) @@ -46,7 +49,7 @@ public Page findByDates(LocalDateTime fromDate, LocalDateTime toDate } public Optional find(Long id) { - return Optional.ofNullable(persistenceAuditEventRepository.findById(id).orElse(null)) + return persistenceAuditEventRepository.findById(id) .map(auditEventConverter::convertToAuditEvent); } } diff --git a/src/main/java/org/radarbase/management/web/rest/AuditResource.java b/src/main/java/org/radarbase/management/web/rest/AuditResource.java index 7ae12f3c2..747c3238a 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuditResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuditResource.java @@ -30,10 +30,13 @@ @RestController @RequestMapping("/management/audits") public class AuditResource { - @Autowired - private AuditEventService auditEventService; - @Autowired - private AuthService authService; + private final AuditEventService auditEventService; + private final AuthService authService; + + public AuditResource(AuditEventService auditEventService, AuthService authService) { + this.auditEventService = auditEventService; + this.authService = authService; + } /** * GET /audits : get a page of AuditEvents. diff --git a/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java index b7964196c..eed2114c7 100644 --- a/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AuditResourceIntTest.java @@ -20,7 +20,6 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.mock.web.MockFilterConfig; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; @@ -76,13 +75,10 @@ class AuditResourceIntTest { @BeforeEach public void setUp() throws ServletException { MockitoAnnotations.initMocks(this); - AuditEventService auditEventService = new AuditEventService(); - ReflectionTestUtils.setField(auditEventService, "persistenceAuditEventRepository", - auditEventRepository); - ReflectionTestUtils.setField(auditEventService, "auditEventConverter", auditEventConverter); - AuditResource auditResource = new AuditResource(); - ReflectionTestUtils.setField(auditResource, "auditEventService", auditEventService); - ReflectionTestUtils.setField(auditResource, "authService", authService); + AuditEventService auditEventService = new AuditEventService( + auditEventRepository, + auditEventConverter); + AuditResource auditResource = new AuditResource(auditEventService, authService); JwtAuthenticationFilter filter = OAuthHelper.createAuthenticationFilter(); filter.init(new MockFilterConfig()); From be075905f0cda79565c2b1a67d41632aad6ff008 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 13:40:56 +0100 Subject: [PATCH 016/120] Removed deprecated oauth-client-util --- Dockerfile | 1 - README.md | 1 - oauth-client-util/.gitignore | 29 -- oauth-client-util/README.md | 80 ------ oauth-client-util/build.gradle | 35 --- .../radarbase/exception/TokenException.java | 21 -- .../oauth/OAuth2AccessTokenDetails.java | 173 ------------ .../org/radarbase/oauth/OAuth2Client.java | 216 --------------- .../unit/OAuth2AccessTokenDetailsTest.java | 38 --- .../oauth/unit/OAuth2ClientTest.java | 254 ------------------ settings.gradle | 1 - .../management/service/AuditEventService.java | 10 +- .../management/web/rest/AuditResource.java | 1 - 13 files changed, 6 insertions(+), 854 deletions(-) delete mode 100644 oauth-client-util/.gitignore delete mode 100644 oauth-client-util/README.md delete mode 100644 oauth-client-util/build.gradle delete mode 100644 oauth-client-util/src/main/java/org/radarbase/exception/TokenException.java delete mode 100644 oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2AccessTokenDetails.java delete mode 100644 oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2Client.java delete mode 100644 oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2AccessTokenDetailsTest.java delete mode 100644 oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2ClientTest.java diff --git a/Dockerfile b/Dockerfile index 7c74a478d..0065916e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ RUN yarn install --network-timeout 1000000 COPY gradle gradle COPY gradlew build.gradle gradle.properties settings.gradle /code/ COPY radar-auth/build.gradle radar-auth/ -COPY oauth-client-util/build.gradle oauth-client-util/ RUN ./gradlew downloadDependencies diff --git a/README.md b/README.md index 66110b03c..9c68c920a 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,6 @@ For more information refer to [Using Docker and Docker-Compose][], this page als Please find the links for some of the documentation per category/component * [management-portal-javadoc](https://radar-base.github.io/ManagementPortal/management-portal-javadoc/) -* [oauth-client-util-javadoc](https://radar-base.github.io/ManagementPortal/oauth-client-util-javadoc/) * [radar-auth-javadoc](https://radar-base.github.io/ManagementPortal/radar-auth-javadoc/) * [managementportal-client-javadoc](https://radar-base.github.io/ManagementPortal/managementportal-client-javadoc/) * [Swagger 2.0 apidoc](https://radar-base.github.io/ManagementPortal/apidoc/swagger.json) diff --git a/oauth-client-util/.gitignore b/oauth-client-util/.gitignore deleted file mode 100644 index 3f145fe4b..000000000 --- a/oauth-client-util/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -.gradle -build/ - -# Ignore Gradle GUI config -gradle-app.setting - -# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) -!gradle-wrapper.jar - -# Cache of project -.gradletasknamecache - -# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties - -# IDEs and editors -/.idea -/.vscode -.project -.classpath -.c9/ -*.launch -.settings/ - - - -#System Files -.DS_Store -Thumbs.db diff --git a/oauth-client-util/README.md b/oauth-client-util/README.md deleted file mode 100644 index b4b4641a6..000000000 --- a/oauth-client-util/README.md +++ /dev/null @@ -1,80 +0,0 @@ -OAuth2.0 Client utility library -=============================== -This library can be used by client applications that want to use the `client_credentials` OAuth2 -flow. It will manage getting the token and renewing it when necessary. - -Usage ------ - -Quickstart: - -```groovy -repositories { - mavenCentral() -} - -dependencies { - implementation("org.radarbase:oauth-client-util:") -} -``` - -Initializing the client: -```Java -OAuth2Client client = new OAuth2Client() - .clientId("client") - .clientSecret("secret") - .managementPortalUrl("http://localhost:8089") - .addScope("read") - .addScope("write"); -``` -Getting a token: -```Java -try { - OAuth2AccessToken token = client.getAccessToken(); -} -catch (TokenException e) { - // handle error -} -``` -Checking expiry: -```Java -if (token.isExpired()) { - // get a new token - try { - token = client.getAccessToken(); - } - catch (TokenException e) { - // handle error - } -} -``` -Using the token: -```Java -String authorizationHeader = "Authorization: " + token.getTokenType() + " " token.getAccessToken(); -``` - - -Create an `OAuth2Client` object and give it the necessary parameters like this: - -```Java -OAuth2Client client = new OAuth2Client() - .clientId("client") - .clientSecret("secret") - .managementPortalUrl("http://localhost:8089") - .addScope("read") - .addScope("write"); -``` - -Now all you have to do is use the client's `getAccessToken()` method: -```Java -OAuth2AccessToken token = client.getAccessToken(); -``` - -The client will automatically get a new access token if the current one is expired. If it is not -expired, no new access token will be requested from the server. The actual token string is -accessible through `OAuth2AccessToken.getAccessToken()`. - -If there was an issue retrieving the access token, `token.isValid()` will return `false` and you can -check `token.getError()` and `token.getErrorDescription()` to find out more. Note that a valid -token that got expired is still a considered a valid token and will return true on -`token.isValid()`. To check expiry you should call `token.isExpired`. diff --git a/oauth-client-util/build.gradle b/oauth-client-util/build.gradle deleted file mode 100644 index 5a3390cc4..000000000 --- a/oauth-client-util/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -sourceCompatibility = JavaVersion.VERSION_1_8 -targetCompatibility = JavaVersion.VERSION_1_8 - -description = 'This library can be used by client applications that want to use the client_credentials OAuth2 flow. It will manage getting the token and renewing it when necessary.' - -dependencies { - implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: okhttp_version - implementation(platform("com.fasterxml.jackson:jackson-bom:$jackson_version")) - implementation group: 'com.fasterxml.jackson.core' , name: 'jackson-databind' - implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml' - - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version - testImplementation group: 'com.github.tomakehurst', name: 'wiremock', version: '2.27.2' - testImplementation group: 'org.mockito', name: 'mockito-core', version: mockito_version - testImplementation 'org.glassfish.jersey.core:jersey-common:3.1.0' - testRuntimeOnly group: 'org.slf4j', name: 'slf4j-simple', version: slf4j_version - testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version -} - -test { - testLogging { - exceptionFormat = 'full' - } - useJUnitPlatform() -} - -task ghPagesJavadoc(type: Copy, dependsOn: javadoc) { - from javadoc.destinationDir - into file("$rootDir/public/oauth-client-util-javadoc") -} - -ext.projectLanguage = "java" - -apply from: '../gradle/style.gradle' -apply from: '../gradle/publishing.gradle' diff --git a/oauth-client-util/src/main/java/org/radarbase/exception/TokenException.java b/oauth-client-util/src/main/java/org/radarbase/exception/TokenException.java deleted file mode 100644 index b4020bf1c..000000000 --- a/oauth-client-util/src/main/java/org/radarbase/exception/TokenException.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.radarbase.exception; - -import java.security.GeneralSecurityException; - -/** - * Created by dverbeec on 31/08/2017. - */ -public class TokenException extends GeneralSecurityException { - - public TokenException() { - super(); - } - - public TokenException(String message) { - super(message); - } - - public TokenException(Throwable cause) { - super(cause); - } -} diff --git a/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2AccessTokenDetails.java b/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2AccessTokenDetails.java deleted file mode 100644 index b2fbe83c6..000000000 --- a/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2AccessTokenDetails.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright 2017 King's College London - * - * 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 - * - * http://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.radarbase.oauth; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.radarbase.exception.TokenException; - -import java.time.Instant; - -/** - * This class captures the response from ManagementPortal's /oauth/token endpoint. The actual access - * token can be retrieved with the {@link #getAccessToken()} method. This is a token in JWT format, - * and can be parsed with a JWT library of your preference. You can use {@link #isValid()} to check - * if you got a valid token, or an error response. If {@link #isValid()} returns false, - * you can use {@link #getError()} and {@link #getErrorDescription()} to find the error message - * that was returned by ManagementPortal. You can also use {@link #isExpired()} to find out if the - * token has expired or not. However it is advised to use the {@link OAuth2Client} class, as it will - * automatically manage token refreshing and checking validity. - */ -public class OAuth2AccessTokenDetails { - - @JsonProperty("access_token") - private String accessToken; - - @JsonProperty("token_type") - private String tokenType; - - @JsonProperty("expires_in") - private long expiresIn; - - @JsonProperty("scope") - private String scope; - - @JsonProperty("sub") - private String subject; - - @JsonProperty("iss") - private String issuer; - - @JsonProperty("iat") - private long issueDate; - - @JsonProperty("jti") - private String jsonWebTokenId; - - @JsonProperty("error") - private String error; - - @JsonProperty("error_description") - private String errorDescription; - - @JsonProperty("message") - private String message; - - public String getAccessToken() { - return accessToken; - } - - public String getTokenType() { - return tokenType; - } - - public long getExpiresIn() { - return expiresIn; - } - - public String getScope() { - return scope; - } - - public String getSubject() { - return subject; - } - - public String getIssuer() { - return issuer; - } - - public long getIssueDate() { - return issueDate; - } - - public String getJsonWebTokenId() { - return jsonWebTokenId; - } - - public String getError() { - return error; - } - - /** - * Get the error description. - * - *

Some errors cause an error description to be populated in the {@code - * error_description} field, other cause the {@code message} field to be populated. This - * method first checks for the {@code error_description} field, and returns it if not null. - * Otherwise it checks the {@code message} field and returns that if not null. If both - * fields are null this method returns an empty string.

- * @return the error description - */ - public String getErrorDescription() { - // some errors give error_description field, some give message field - if (errorDescription != null) { - return errorDescription; - } else if (message != null) { - return message; - } else { - return ""; - } - } - - public boolean isExpired() { - return Instant.now().isAfter(getExpiryDate()); - } - - /** - * Check the validity of this token. - * @return {@code true} if the {@code accessToken} field is not {@code null} and the {@code - * error} field is null - */ - public boolean isValid() { - return accessToken != null && error == null; - } - - public Instant getExpiryDate() { - return Instant.ofEpochSecond(issueDate + expiresIn); - } - - /** - * Parse an access token response into an {@link OAuth2AccessTokenDetails} object. - * @param responseBody the response body - * @return an instance of this class - * @throws TokenException if the response can not be parsed to an instance of this class - */ - public static OAuth2AccessTokenDetails getObject(String responseBody) throws TokenException { - ObjectMapper mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - - try { - OAuth2AccessTokenDetails result = mapper.readValue(responseBody, - OAuth2AccessTokenDetails.class); - if (result.getError() != null) { - throw new TokenException(result.getError() + ": " + result.getErrorDescription()); - } - if (result.getAccessToken() == null) { - // we didn't catch an error but also didn't get a token (this could happen e.g. when - // we receive an empty JSON entity as a response, or a JSON entity which does - // not have the right fields - throw new TokenException("An unexpected error occured." - + " Response body was: " + responseBody); - } - return result; - } catch (Exception e) { - throw new TokenException(e); - } - } -} diff --git a/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2Client.java b/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2Client.java deleted file mode 100644 index f9bc5aa7d..000000000 --- a/oauth-client-util/src/main/java/org/radarbase/oauth/OAuth2Client.java +++ /dev/null @@ -1,216 +0,0 @@ -package org.radarbase.oauth; - -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import okhttp3.Credentials; -import okhttp3.FormBody; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.radarbase.exception.TokenException; - -/** - * Class for handling OAuth2 client credentials grant with the RADAR platform's ManagementPortal. - * - *

Although it is designed with the ManagementPortal in mind, any identity server based on the - * Spring OAuth library and using JWT as a token should be compatible. The {@link #getValidToken()} - * method provides access to the {@link OAuth2AccessTokenDetails} instance, and will request a new - * access token if the current one is expired. It will throw a {@link TokenException} if anything - * went wrong. So to get the actual token you will call - * {@code client.getAccessToken().getAccessToken()}. This token is in JWT format and can be - * parsed by a JWT library of your preference. Note: by default, the public key endpoint on - * ManagementPortal is located at {@code /oauth/token_key}.

- * - *

See the test cases for this class for examples on usage. Also see - * {@link OAuth2AccessTokenDetails} for more info on how to use it.

- * - *

This class is thread-safe. - */ -// using builder pattern. -@SuppressWarnings("PMD.MissingStaticMethodInNonInstantiatableClass") -public class OAuth2Client { - private static final Duration MINIMUM_VALIDITY = Duration.ofSeconds(10); - - private final URL tokenEndpoint; - private final Set scope; - private final OkHttpClient httpClient; - private final String clientCredentials; - - private OAuth2AccessTokenDetails token; - - private OAuth2Client(URL endpoint, String clientCredentials, Set scopes, - OAuth2AccessTokenDetails token, OkHttpClient client) { - this.tokenEndpoint = endpoint; - this.clientCredentials = clientCredentials; - this.scope = scopes; - this.token = token; - httpClient = client; - } - - public URL getTokenEndpoint() { - return tokenEndpoint; - } - - public Set getScope() { - return scope; - } - - /** - * Get the current token, valid or not. - * @return the current token - */ - public synchronized OAuth2AccessTokenDetails getToken() { - return token; - } - - - /** - * Get the access token. This method will automatically request a new access token if the - * current one will expire before 10 seconds. - * @return the access token - * @throws TokenException if a new access token could not be fetched - */ - public OAuth2AccessTokenDetails getValidToken() throws TokenException { - return getValidToken(MINIMUM_VALIDITY); - } - - /** - * Get the access token. This method will automatically request a new access token if the - * current one will expire before given validity duration. - * @param validity time until the current token will become invalid - * @return the access token - * @throws TokenException if a new access token could not be fetched - */ - public OAuth2AccessTokenDetails getValidToken(Duration validity) throws TokenException { - synchronized (this) { - if (isTokenValidFor(validity)) { - return token; - } - } - return refreshToken(); - } - - /** - * Check whether given token is still valid for the given amount of time. - * @param timeStillValid duration that the token should still be valid. - * @return {@code true} if the token is valid for given duration, {@code false} otherwise. - */ - public synchronized boolean isTokenValidFor(Duration timeStillValid) { - return token.isValid() - && Instant.now().plus(timeStillValid).isBefore(token.getExpiryDate()); - } - - /** - * Refresh the current token. This will update the token value of this class. - * @return the new refreshed token - * @throws TokenException if the token could not be refreshed. - */ - public OAuth2AccessTokenDetails refreshToken() throws TokenException { - // build the form to post to the token endpoint - FormBody body = new FormBody.Builder() - .add("grant_type", "client_credentials") - .add("scope", String.join(" ", scope)) - .build(); - - // build the POST request to the token endpoint with the form data - Request request = new Request.Builder() - .addHeader("Accept", "application/json") - .addHeader("Authorization", clientCredentials) - .url(getTokenEndpoint()) - .post(body) - .build(); - - // make the client execute the POST request - try (Response response = httpClient.newCall(request).execute()) { - if (response.isSuccessful()) { - if (response.body() == null) { - throw new TokenException("No response from server"); - } - OAuth2AccessTokenDetails localToken = OAuth2AccessTokenDetails.getObject( - response.body().string()); - - synchronized (this) { - token = localToken; - } - - return localToken; - } else { - throw new TokenException("Cannot get a valid token : Response-code :" - + response.code() + " received when requesting token from server with " - + "message " + response.message()); - } - } catch (IOException e) { - throw new TokenException(e); - } - } - - /** Builder for an OAuth2 client. The endpoint and credentials settings are mandatory. */ - public static class Builder { - private URL tokenEndpoint; - private final Set scopeSet = new HashSet<>(); - private OAuth2AccessTokenDetails currentToken = new OAuth2AccessTokenDetails(); - private OkHttpClient okHttpClient; - private String clientCredentials; - - public Builder endpoint(URL url) { - this.tokenEndpoint = url; - return this; - } - - public Builder endpoint(URL mpBaseUrl, String tokenPath) throws MalformedURLException { - tokenEndpoint = new URL(mpBaseUrl, tokenPath); - return this; - } - - public Builder credentials(String id, String secret) { - clientCredentials = Credentials.basic(id, secret); - return this; - } - - public Builder scopes(String... scopes) { - scopeSet.addAll(Arrays.asList(scopes)); - return this; - } - - public Builder token(OAuth2AccessTokenDetails token) { - currentToken = token; - return this; - } - - public Builder httpClient(OkHttpClient client) { - okHttpClient = client; - return this; - } - - /** - * Build an OAuth2Client based on the settings given. This will construct a new - * HTTP client if none was provided. - * @return a new OAuth2Client - * @throws IllegalStateException if the client credentials or the endpoint are not set. - */ - public OAuth2Client build() { - if (clientCredentials == null) { - throw new IllegalStateException("Client credentials missing"); - } - if (tokenEndpoint == null) { - throw new IllegalStateException("Token endpoint missing"); - } - if (okHttpClient == null) { - okHttpClient = new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build(); - } - return new OAuth2Client(tokenEndpoint, clientCredentials, scopeSet, currentToken, - okHttpClient); - } - } -} diff --git a/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2AccessTokenDetailsTest.java b/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2AccessTokenDetailsTest.java deleted file mode 100644 index 3f41825e3..000000000 --- a/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2AccessTokenDetailsTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.radarbase.oauth.unit; - -import org.junit.jupiter.api.Test; -import org.radarbase.exception.TokenException; -import org.radarbase.oauth.OAuth2AccessTokenDetails; - -import java.time.Instant; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Created by dverbeec on 31/08/2017. - */ -class OAuth2AccessTokenDetailsTest { - - @Test - void testNewTokenIsExpired() { - OAuth2AccessTokenDetails token = new OAuth2AccessTokenDetails(); - assertTrue(token.isExpired()); - } - - @Test - void testNewTokenIsInvalid() { - OAuth2AccessTokenDetails token = new OAuth2AccessTokenDetails(); - assertFalse(token.isValid()); - } - - @Test - void testTokenNotExpired() throws TokenException { - String body = - "{\"expires_in\":30" - + ",\"iat\":" + Instant.now().getEpochSecond() - + ",\"access_token\":\"abcdef\"}"; - OAuth2AccessTokenDetails token = OAuth2AccessTokenDetails.getObject(body); - assertFalse(token.isExpired()); - } -} diff --git a/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2ClientTest.java b/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2ClientTest.java deleted file mode 100644 index 7f9cbd34b..000000000 --- a/oauth-client-util/src/test/java/org/radarbase/oauth/unit/OAuth2ClientTest.java +++ /dev/null @@ -1,254 +0,0 @@ -package org.radarbase.oauth.unit; - -import com.github.tomakehurst.wiremock.WireMockServer; -import com.github.tomakehurst.wiremock.core.WireMockConfiguration; -import okhttp3.OkHttpClient; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import org.radarbase.exception.TokenException; -import org.radarbase.oauth.OAuth2AccessTokenDetails; -import org.radarbase.oauth.OAuth2Client; - -import jakarta.ws.rs.core.HttpHeaders; -import java.net.MalformedURLException; -import java.net.URL; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.TimeUnit; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.post; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * Created by dverbeec on 31/08/2017. - */ -class OAuth2ClientTest { - private static final String accessToken = - "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyYWRhcl9yZXN0YXBp" - + "Iiwi19NYW5hZ2VtZW50UG9ydGFsIl0sInNvdXJjZXMiOltdLCJzY29wZSI6WyJyZWFkIl0sImlzcyI6Ik1hb" - + "mFnZW1lbnleHAiOjE1MDQwODU3MzEsImlhdCI6MTUwNDA4MzkzMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0" - + "VSIl0sImp0aSI6TJmMTItNDQxMi1iZGVjLTc5YzMxNWY3NGM3OSIsImNsaWVudF9pZCI6InJhZGFyX3Jlc3R" - + "hcGkifQ.J0TEFQAUnH9RFaplURHrbeLelgbAr3CS7os_Y5S6836TFZyDe4mz4LqhxJLquXxTNP3DYddOKDD_" - + "RQ1t0nIDfx0hFJawPB3AjVqobRLOtFQWWdtYYmPbDXVQkdK41iVDhl_15BBxxOlT0pFQfkq4wk22ubq5cg8V" - + "Z57xDkrfgaIbdowntnK9GqLy6mDtaPdQV23VDr3whkjEq2YJ9AQBj4KiOWEVAYuNwhZFwHwInsYPZTs2RNK5" - + "WkdW2pe4sXGc7BDgUykpUWEMtL7BoyTZEGO5VqDkwcbio1zJDGB5dPm8VHWtlg4tH098BhsFrVE3zOJ9D0Ai" - + "62JWZkzr24lH9QjBwruxifyu4AvcLp_AxmO7m_r1bLcDuh6Yt4Ntm1bhGoB_PrygiOFPMn2-VnUH9zTxpZaK" - + "UH9CHHKOVdcK9N3gLKo30ETVDib-bZS-rDESHDvnYppgTH6i31wfjl80NCQhSpB3GyXAR2YHfoTj4VbEzGKs" - + "LEfS7g-4hSH2kY4-srOAH5TeI2snKbh76mFL8SOTuZrHf-F5KwWPqB82OzAr899eFk6uiNd5Uz7dICyEKyS7" - + "v-HQ"; - private static final String accessTokenId = "5b9fc645-2f12-4412-bdec-79c315f74c79"; - private long tokenIssueDate; - - private static final String invalidScopeResponse = "{\n" - + " \"error\" : \"invalid_scope\",\n" - + " \"error_description\" : \"Invalid scope: write\",\n" - + " \"scope\" : \"read\"\n" - + "}\n"; - - private static final String invalidCredentialsResponse = "{\n" - + " \"timestamp\" : \"2017-08-31T09:50:19.779+0000\",\n" - + " \"status\" : 401,\n" - + " \"error\" : \"Unauthorized\",\n" - + " \"message\" : \"Bad credentials\",\n" - + " \"path\" : \"/oauth/token\"" - + "}"; - - private static final String invalidGrantTypeResponse = "{\n" - + " \"error\" : \"invalid_client\",\n" - + " \"error_description\" : \"Unauthorized grant type: client_credentials\"\n" - + "}"; - - private static final String notFoundResponse = "{\n" - + " \"timestamp\" : \"2017-08-31T12:00:56.274+0000\",\n" - + " \"status\" : 404,\n" - + " \"error\" : \"Not Found\",\n" - + " \"message\" : \"Not Found\",\n" - + " \"path\" : \"/oauth/token\"\n" - + "}\n"; - - private static OkHttpClient httpClient; - private static WireMockServer wireMockServer; - private OAuth2Client.Builder clientBuilder; - - /** Set up custom HTTP client. */ - @BeforeAll - public static void setUpClass() { - wireMockServer = new WireMockServer(new WireMockConfiguration().port(8089)); - wireMockServer.start(); - httpClient = new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build(); - } - - @BeforeEach - public void init() throws MalformedURLException { - tokenIssueDate = Instant.now().getEpochSecond(); - clientBuilder = new OAuth2Client.Builder() - .credentials("client", "secret") - .endpoint(new URL("http://localhost:8089/oauth/token")); - } - - @AfterEach - public void reset() { - wireMockServer.resetAll(); - } - - @AfterAll - public static void tearDown() { - wireMockServer.stop(); - } - - @Test - void testValidTokenResponse() throws TokenException { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(200) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(successfulResponse()))); - OAuth2Client client = clientBuilder - .scopes("read") - .httpClient(httpClient) - .build(); - OAuth2AccessTokenDetails token = client.getValidToken(); - assertTrue(token.isValid()); - assertFalse(token.isExpired()); - assertEquals(accessToken, token.getAccessToken()); - assertEquals("bearer", token.getTokenType()); - assertEquals(1799L, token.getExpiresIn()); - assertTrue(client.isTokenValidFor(Duration.ofSeconds(1700))); - assertFalse(client.isTokenValidFor(Duration.ofSeconds(1900))); - assertEquals(tokenIssueDate, token.getIssueDate()); - assertEquals("radar_restapi", token.getSubject()); - assertEquals("read", token.getScope()); - assertEquals(accessTokenId, token.getJsonWebTokenId()); - assertEquals("ManagementPortal", token.getIssuer()); - } - - @Test - void testInvalidScope() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(400) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(invalidScopeResponse))); - OAuth2Client client = clientBuilder - .scopes("write") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken()); - } - - @Test - void testInvalidCredentials() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(401) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(invalidCredentialsResponse))); - OAuth2Client client = clientBuilder - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken()); - } - - @Test - void testInvalidGrantType() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(401) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(invalidGrantTypeResponse))); - OAuth2Client client = clientBuilder - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken(Duration.ofSeconds(30))); - } - - @Test - void testInvalidMapping() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(401) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(invalidTypesResponse()))); - OAuth2Client client = clientBuilder - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken(Duration.ofSeconds(30))); - } - - @Test - void testUnreachableServer() throws MalformedURLException { - // no http stub here so the location will be unreachable - OAuth2Client client = clientBuilder - // different port in case wiremock is not cleaned up yet - .endpoint(new URL("http://localhost:9000")) - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken()); - } - - @Test - void testParseError() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(200) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/html") - .withBody("Oops, no JSON here"))); - OAuth2Client client = clientBuilder - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken()); - } - - @Test - void testNotFound() { - wireMockServer.stubFor(post(urlEqualTo("/oauth/token")) - .willReturn(aResponse() - .withStatus(404) - .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .withBody(notFoundResponse))); - OAuth2Client client = clientBuilder - .scopes("read") - .build(); - assertThrows(TokenException.class, () -> client.getValidToken()); - } - - private String successfulResponse() { - return "{\n" - + " \"access_token\" : \"" + accessToken + "\",\n" - + " \"token_type\" : \"bearer\",\n" - + " \"expires_in\" : 1799,\n" - + " \"scope\" : \"read\",\n" - + " \"sub\" : \"radar_restapi\",\n" - + " \"sources\" : [ ],\n" - + " \"iss\" : \"ManagementPortal\",\n" - + " \"iat\" : " + tokenIssueDate + ",\n" - + " \"jti\" : \"" + accessTokenId + "\"\n" - + "}"; - } - - private String invalidTypesResponse() { - return "{\n" - + " \"access_token\" : \"" + accessToken + "\",\n" - + " \"token_type\" : \"bearer\",\n" - + " \"expires_in\" : \"tomorrow\",\n" - + " \"scope\" : \"read\",\n" - + " \"sub\" : \"radar_restapi\",\n" - + " \"sources\" : [ ],\n" - + " \"iss\" : \"ManagementPortal\",\n" - + " \"iat\" : " + tokenIssueDate + ",\n" - + " \"jti\" : \"" + accessTokenId + "\"\n" - + "}"; - } -} diff --git a/settings.gradle b/settings.gradle index bb323b41a..b22204b40 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,3 @@ rootProject.name = 'management-portal' -include ':oauth-client-util' include ':radar-auth' include ':managementportal-client' diff --git a/src/main/java/org/radarbase/management/service/AuditEventService.java b/src/main/java/org/radarbase/management/service/AuditEventService.java index 7f7c36b2a..219c81f92 100644 --- a/src/main/java/org/radarbase/management/service/AuditEventService.java +++ b/src/main/java/org/radarbase/management/service/AuditEventService.java @@ -1,16 +1,16 @@ package org.radarbase.management.service; -import java.time.LocalDateTime; -import java.util.Optional; import org.radarbase.management.config.audit.AuditEventConverter; import org.radarbase.management.repository.PersistenceAuditEventRepository; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.audit.AuditEvent; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Optional; + /** * Service for managing audit events.

This is the default implementation to support SpringBoot * Actuator AuditEventRepository

@@ -23,7 +23,9 @@ public class AuditEventService { private final AuditEventConverter auditEventConverter; - public AuditEventService(PersistenceAuditEventRepository persistenceAuditEventRepository, AuditEventConverter auditEventConverter) { + public AuditEventService( + PersistenceAuditEventRepository persistenceAuditEventRepository, + AuditEventConverter auditEventConverter) { this.persistenceAuditEventRepository = persistenceAuditEventRepository; this.auditEventConverter = auditEventConverter; } diff --git a/src/main/java/org/radarbase/management/web/rest/AuditResource.java b/src/main/java/org/radarbase/management/web/rest/AuditResource.java index 747c3238a..a7f87675d 100644 --- a/src/main/java/org/radarbase/management/web/rest/AuditResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AuditResource.java @@ -5,7 +5,6 @@ import org.radarbase.management.service.AuditEventService; import org.radarbase.management.service.AuthService; import org.radarbase.management.web.rest.util.PaginationUtil; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.audit.AuditEvent; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; From c6700295117c3fc0e717518ede04214d196bbd51 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 13:46:18 +0100 Subject: [PATCH 017/120] [GA] Bump snyk version --- .github/workflows/scheduled-snyk.yaml | 2 +- .github/workflows/snyk.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scheduled-snyk.yaml b/.github/workflows/scheduled-snyk.yaml index bce433e2d..370e0ec0d 100644 --- a/.github/workflows/scheduled-snyk.yaml +++ b/.github/workflows/scheduled-snyk.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v3 - uses: snyk/actions/setup@master with: - snyk-version: v1.996.0 + snyk-version: v1.1032.0 - uses: actions/setup-node@v3 with: diff --git a/.github/workflows/snyk.yaml b/.github/workflows/snyk.yaml index 9dec5abc8..747ec40fb 100644 --- a/.github/workflows/snyk.yaml +++ b/.github/workflows/snyk.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v3 - uses: snyk/actions/setup@master with: - snyk-version: v1.996.0 + snyk-version: v1.1032.0 - uses: actions/setup-node@v3 with: From 5299ad352bdb765b2d3a4dc5a124b816ba6a0a21 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 14:39:19 +0100 Subject: [PATCH 018/120] Cleanup build logic --- build.gradle | 57 +++++++++------------------------------------------- 1 file changed, 9 insertions(+), 48 deletions(-) diff --git a/build.gradle b/build.gradle index 0224c5019..1f7b8882d 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import java.util.concurrent.TimeUnit - buildscript { repositories { mavenCentral() @@ -101,7 +99,7 @@ springBoot { } if (OperatingSystem.current().isWindows()) { - task pathingJar(type: Jar) { + tasks.register('pathingJar', Jar) { dependsOn configurations.runtime archiveAppendix.set('pathing') @@ -236,7 +234,7 @@ dependencies { testImplementation "org.hamcrest:hamcrest-library" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" - annotationProcessor group: 'org.springframework.boot', name: 'spring-boot-configuration-processor' + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") } dependencyManagement { @@ -253,7 +251,7 @@ clean { delete "target" } -task cleanResources(type: Delete) { +tasks.register('cleanResources', Delete) { delete 'build/resources' } @@ -261,15 +259,17 @@ wrapper { gradleVersion '8.0.2' } -task stage(dependsOn: 'bootWar') { +tasks.register('stage') { + dependsOn 'bootWar' } -task ghPagesJavadoc(type: Copy, dependsOn: javadoc) { +tasks.register('ghPagesJavadoc', Copy) { from javadoc.destinationDir into file("$rootDir/public/management-portal-javadoc") + dependsOn tasks.named('javadoc') } -task ghPagesOpenApiSpec(type: Copy) { +tasks.register('ghPagesOpenApiSpec', Copy) { from file("$buildDir/swagger-spec") into file("$rootDir/public/apidoc") } @@ -278,7 +278,7 @@ compileJava.dependsOn processResources processResources.dependsOn cleanResources, bootBuildInfo bootBuildInfo.mustRunAfter cleanResources -task downloadDependencies { +tasks.register('downloadDependencies') { description "Pre-downloads dependencies" configurations.compileClasspath.files configurations.runtimeClasspath.files @@ -296,45 +296,6 @@ nexusPublishing { } } -class TimingsListener implements TaskExecutionListener, BuildListener { - private long startTime - private timings = [] - - @Override - void beforeExecute(Task task) { - startTime = System.nanoTime() - } - - @Override - void afterExecute(Task task, TaskState taskState) { - def ms = TimeUnit.MILLISECONDS.convert(System.nanoTime() - startTime, TimeUnit.NANOSECONDS); - timings.add([ms, task.path]) - task.project.logger.warn "${task.path} took ${ms}ms" - } - - @Override - void buildFinished(BuildResult result) { - println "Task timings:" - for (timing in timings) { - if (timing[0] >= 50) { - printf "%7sms %s\n", timing - } - } - } - - @Override - void projectsEvaluated(Gradle gradle) {} - - @Override - void projectsLoaded(Gradle gradle) {} - - @Override - void settingsEvaluated(Settings settings) {} -} -if (project.hasProperty('dev')) { - gradle.addListener new TimingsListener() -} - def isNonStable = { String version -> def stableKeyword = ["RELEASE", "FINAL", "GA"].any { version.toUpperCase().contains(it) } def regex = /^[0-9,.v-]+(-r)?$/ From fe8fa1b1274eb7203b490ebb4ad74fa88ebb5e95 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Fri, 17 Mar 2023 12:10:23 +0100 Subject: [PATCH 019/120] Bump versions --- build.gradle | 20 ++++++++++++++------ gradle.properties | 18 +++++++++--------- managementportal-client/build.gradle | 2 +- radar-auth/build.gradle | 2 +- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/build.gradle b/build.gradle index 1f7b8882d..0e92bd0c2 100644 --- a/build.gradle +++ b/build.gradle @@ -16,14 +16,14 @@ buildscript { plugins { id 'application' id 'org.springframework.boot' version "${spring_boot_version}" - id "com.github.node-gradle.node" version "3.5.0" + id "com.github.node-gradle.node" version "3.5.1" id "io.spring.dependency-management" version "1.1.0" - id 'de.undercouch.download' version '5.3.0' apply false - id "io.github.gradle-nexus.publish-plugin" version "1.1.0" - id("com.github.ben-manes.versions") version "0.43.0" + id 'de.undercouch.download' version '5.3.1' apply false + id "io.github.gradle-nexus.publish-plugin" version "1.3.0" + id("com.github.ben-manes.versions") version "0.46.0" id 'org.jetbrains.kotlin.jvm' version "1.8.10" id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' apply false - id 'org.jetbrains.dokka' version "1.7.20" + id 'org.jetbrains.dokka' version "1.8.10" } apply plugin: 'org.springframework.boot' @@ -303,7 +303,15 @@ def isNonStable = { String version -> } tasks.named("dependencyUpdates").configure { + doFirst { + allprojects { + repositories.removeAll { + it instanceof MavenArtifactRepository && it.url.toString().contains("snapshot") + } + } + } rejectVersionIf { - isNonStable(it.candidate.version) + it.currentVersion.split('\\.')[0] != it.candidate.version.split('\\.')[0] + || isNonStable(it.candidate.version) } } diff --git a/gradle.properties b/gradle.properties index a41a7f672..903daa2cd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,28 +6,28 @@ hazelcast_version=5.2.0 hikaricp_version=5.0.1 liquibase_slf4j_version=4.1.0 liquibase_version=4.17.2 -postgresql_version=42.5.0 -springdoc_version=1.6.12 +postgresql_version=42.5.4 +springdoc_version=1.6.15 spring_boot_version=2.7.9 -spring_framework_version=5.3.23 +spring_framework_version=5.3.25 spring_data_version=2021.2.5 spring_session_version=2021.2.0 gatling_version=3.8.4 mapstruct_version=1.5.3.Final -jackson_version=2.14.0 +jackson_version=2.14.2 javax_xml_bind_version=2.3.3 javax_jaxb_core_version=2.3.0.1 javax_jaxb_runtime_version=2.3.4 javax_activation=1.1.1 mockito_version=4.8.1 -slf4j_version=2.0.3 -logback_version=1.4.4 -oauth_jwt_version=4.2.1 -junit_version=5.9.1 +slf4j_version=2.0.6 +logback_version=1.4.6 +oauth_jwt_version=4.3.0 +junit_version=5.9.2 okhttp_version=4.10.0 hsqldb_version=2.7.1 coroutines_version=1.6.4 -ktor_version=2.1.3 +ktor_version=2.2.4 kotlin.code.style=official org.gradle.vfs.watch=true diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index 11fe25c33..c68540bea 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -28,7 +28,7 @@ dependencies { implementation("org.radarbase:radar-commons-kotlin:0.16.0-SNAPSHOT") api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) - api(platform('io.ktor:ktor-bom:2.2.3')) + api(platform("io.ktor:ktor-bom:$ktor_version")) api("io.ktor:ktor-client-core") api("io.ktor:ktor-client-auth") diff --git a/radar-auth/build.gradle b/radar-auth/build.gradle index 2d0a448c9..484a097cd 100644 --- a/radar-auth/build.gradle +++ b/radar-auth/build.gradle @@ -21,7 +21,7 @@ dependencies { implementation("org.radarbase:radar-commons-kotlin:0.16.0-SNAPSHOT") - implementation(platform('io.ktor:ktor-bom:2.2.3')) + implementation(platform("io.ktor:ktor-bom:$ktor_version")) implementation("io.ktor:ktor-client-core") implementation("io.ktor:ktor-client-cio") implementation("io.ktor:ktor-client-content-negotiation") From eaa8a89bee6629b87ae2420efd96ceac4b22d603 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 25 Apr 2023 15:11:34 +0200 Subject: [PATCH 020/120] Add token copy method --- .../org/radarbase/auth/token/DataRadarToken.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt index 52413a113..534afe079 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt @@ -86,4 +86,21 @@ data class DataRadarToken( override val clientId: String? = null, ): RadarToken, Serializable { override fun copyWithRoles(roles: Set): DataRadarToken = copy(roles = roles) + + companion object { + fun RadarToken.copy(): DataRadarToken = DataRadarToken( + roles = roles, + scopes = scopes, + sources = sources, + grantType = grantType, + subject = subject, + username = username, + issuedAt = issuedAt, + expiresAt = expiresAt, + audience = audience, + token = token, + issuer = issuer, + type = type, + ) + } } From e218d7afc1869b97451096f53bde29f672da437c Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 25 Apr 2023 15:21:19 +0200 Subject: [PATCH 021/120] Moved SourceType loading to SourceTypeService --- .../management/config/SourceTypeLoader.java | 117 ++---------------- .../management/service/SourceTypeService.java | 88 +++++++++++++ .../management/web/rest/util/HttpUtil.java | 35 ------ 3 files changed, 97 insertions(+), 143 deletions(-) delete mode 100644 src/main/java/org/radarbase/management/web/rest/util/HttpUtil.java diff --git a/src/main/java/org/radarbase/management/config/SourceTypeLoader.java b/src/main/java/org/radarbase/management/config/SourceTypeLoader.java index d46dfc4f0..dbe666d7d 100644 --- a/src/main/java/org/radarbase/management/config/SourceTypeLoader.java +++ b/src/main/java/org/radarbase/management/config/SourceTypeLoader.java @@ -1,27 +1,19 @@ package org.radarbase.management.config; -import java.net.MalformedURLException; -import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.List; -import org.radarbase.management.domain.SourceData; -import org.radarbase.management.domain.SourceType; -import org.radarbase.management.repository.SourceDataRepository; -import org.radarbase.management.repository.SourceTypeRepository; -import org.radarbase.management.service.catalog.CatalogSourceData; + +import org.radarbase.management.service.SourceTypeService; import org.radarbase.management.service.catalog.CatalogSourceType; import org.radarbase.management.service.catalog.SourceTypeResponse; -import org.radarbase.management.service.mapper.CatalogSourceDataMapper; -import org.radarbase.management.service.mapper.CatalogSourceTypeMapper; -import org.radarbase.management.web.rest.util.HttpUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; /** @@ -35,20 +27,11 @@ public class SourceTypeLoader implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(SourceTypeLoader.class); @Autowired - private SourceTypeRepository sourceTypeRepository; - - @Autowired - private SourceDataRepository sourceDataRepository; + private SourceTypeService sourceTypeService; @Autowired private ManagementPortalProperties managementPortalProperties; - @Autowired - private CatalogSourceTypeMapper catalogSourceTypeMapper; - - @Autowired - private CatalogSourceDataMapper catalogSourceDataMapper; - @Override public void run(String... args) { if (!managementPortalProperties.getCatalogueServer().isEnableAutoImport()) { @@ -59,12 +42,8 @@ public void run(String... args) { String catalogServerUrl = managementPortalProperties.getCatalogueServer().getServerUrl(); try { - if (HttpUtil.isReachable(new URL(catalogServerUrl))) { - log.warn("Catalog Service {} is unreachable", catalogServerUrl); - return; - } RestTemplate restTemplate = new RestTemplate(); - log.debug("Requesting source-types from catalogue server..."); + log.debug("Requesting source-types from catalog server..."); ResponseEntity catalogues = restTemplate .getForEntity(catalogServerUrl, SourceTypeResponse.class); SourceTypeResponse catalogueDto = catalogues.getBody(); @@ -77,10 +56,10 @@ public void run(String... args) { addNonNull(catalogSourceTypes, catalogueDto.getActiveSources()); addNonNull(catalogSourceTypes, catalogueDto.getMonitorSources()); addNonNull(catalogSourceTypes, catalogueDto.getConnectorSources()); - saveSourceTypesFromCatalogServer(catalogSourceTypes); - } catch (MalformedURLException e) { - log.warn("Invalid Url provided for Catalog server url {} : {}", catalogServerUrl, - e.getMessage()); + sourceTypeService.saveSourceTypesFromCatalogServer(catalogSourceTypes); + } catch (RestClientException e) { + log.warn("Cannot fetch source types from Catalog Service at {}: {}", catalogServerUrl, + e.toString()); } catch (RuntimeException exe) { log.warn("An error has occurred during auto import of source-types: {}", exe .getMessage()); @@ -92,82 +71,4 @@ private static void addNonNull(Collection collection, Collection catalogSourceTypes) { - for (CatalogSourceType catalogSourceType : catalogSourceTypes) { - SourceType sourceType = catalogSourceTypeMapper - .catalogSourceTypeToSourceType(catalogSourceType); - - if (!isSourceTypeValid(sourceType)) { - continue; - } - - // check whether a source-type is already available with given config - if (sourceTypeRepository.hasOneByProducerAndModelAndVersion( - sourceType.getProducer(), sourceType.getModel(), - sourceType.getCatalogVersion())) { - // skip for existing source-types - log.info("Source-type {} is already available ", sourceType.getProducer() - + "_" + sourceType.getModel() - + "_" + sourceType.getCatalogVersion()); - } else { - try { - // create new source-type - sourceType = sourceTypeRepository.save(sourceType); - - // create source-data for the new source-type - for (CatalogSourceData catalogSourceData : catalogSourceType.getData()) { - saveSourceData(sourceType, catalogSourceData); - } - } catch (RuntimeException ex) { - log.error("Failed to import source type {}", sourceType, ex); - } - } - } - log.info("Completed source-type import from catalog-server"); - } - - private void saveSourceData(SourceType sourceType, CatalogSourceData catalogSourceData) { - try { - SourceData sourceData = catalogSourceDataMapper - .catalogSourceDataToSourceData(catalogSourceData); - // sourceDataName should be unique - // generated by combining sourceDataType and source-type configs - sourceData.sourceDataName(sourceType.getProducer() - + "_" + sourceType.getModel() - + "_" + sourceType.getCatalogVersion() - + "_" + sourceData.getSourceDataType()); - sourceData.sourceType(sourceType); - sourceDataRepository.save(sourceData); - } catch (RuntimeException ex) { - log.error("Failed to import source data {}", catalogSourceData, ex); - } - } - - private static boolean isSourceTypeValid(SourceType sourceType) { - if (sourceType.getProducer() == null) { - log.warn("Catalog source-type {} does not have a vendor. " - + "Skipping importing this type", sourceType.getName()); - return false; - } - - if (sourceType.getModel() == null) { - log.warn("Catalog source-type {} does not have a model. " - + "Skipping importing this type", sourceType.getName()); - return false; - } - - if (sourceType.getCatalogVersion() == null) { - log.warn("Catalog source-type {} does not have a version. " - + "Skipping importing this type", sourceType.getName()); - return false; - } - return true; - } } diff --git a/src/main/java/org/radarbase/management/service/SourceTypeService.java b/src/main/java/org/radarbase/management/service/SourceTypeService.java index a163e6e4f..2b6e6f562 100644 --- a/src/main/java/org/radarbase/management/service/SourceTypeService.java +++ b/src/main/java/org/radarbase/management/service/SourceTypeService.java @@ -13,8 +13,12 @@ import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.SourceDataRepository; import org.radarbase.management.repository.SourceTypeRepository; +import org.radarbase.management.service.catalog.CatalogSourceData; +import org.radarbase.management.service.catalog.CatalogSourceType; import org.radarbase.management.service.dto.ProjectDTO; import org.radarbase.management.service.dto.SourceTypeDTO; +import org.radarbase.management.service.mapper.CatalogSourceDataMapper; +import org.radarbase.management.service.mapper.CatalogSourceTypeMapper; import org.radarbase.management.service.mapper.ProjectMapper; import org.radarbase.management.service.mapper.SourceTypeMapper; import org.radarbase.management.web.rest.errors.NotFoundException; @@ -44,6 +48,12 @@ public class SourceTypeService { @Autowired private SourceDataRepository sourceDataRepository; + @Autowired + private CatalogSourceTypeMapper catalogSourceTypeMapper; + + @Autowired + private CatalogSourceDataMapper catalogSourceDataMapper; + @Autowired private ProjectMapper projectMapper; @@ -152,4 +162,82 @@ public List findProjectsBySourceType(String producer, String model, return projectMapper.projectsToProjectDTOs(sourceTypeRepository .findProjectsBySourceType(producer, model, version)); } + + /** + * Converts given {@link CatalogSourceType} to {@link SourceType} and saves it to the databse + * after validations. + * @param catalogSourceTypes list of source-type from catalogue-server. + */ + @Transactional + public void saveSourceTypesFromCatalogServer(List catalogSourceTypes) { + for (CatalogSourceType catalogSourceType : catalogSourceTypes) { + SourceType sourceType = catalogSourceTypeMapper + .catalogSourceTypeToSourceType(catalogSourceType); + + if (!isSourceTypeValid(sourceType)) { + continue; + } + + // check whether a source-type is already available with given config + if (sourceTypeRepository.hasOneByProducerAndModelAndVersion( + sourceType.getProducer(), sourceType.getModel(), + sourceType.getCatalogVersion())) { + // skip for existing source-types + log.info("Source-type {} is already available ", sourceType.getProducer() + + "_" + sourceType.getModel() + + "_" + sourceType.getCatalogVersion()); + } else { + try { + // create new source-type + sourceType = sourceTypeRepository.save(sourceType); + + // create source-data for the new source-type + for (CatalogSourceData catalogSourceData : catalogSourceType.getData()) { + saveSourceData(sourceType, catalogSourceData); + } + } catch (RuntimeException ex) { + log.error("Failed to import source type {}", sourceType, ex); + } + } + } + log.info("Completed source-type import from catalog-server"); + } + + private void saveSourceData(SourceType sourceType, CatalogSourceData catalogSourceData) { + try { + SourceData sourceData = catalogSourceDataMapper + .catalogSourceDataToSourceData(catalogSourceData); + // sourceDataName should be unique + // generated by combining sourceDataType and source-type configs + sourceData.sourceDataName(sourceType.getProducer() + + "_" + sourceType.getModel() + + "_" + sourceType.getCatalogVersion() + + "_" + sourceData.getSourceDataType()); + sourceData.sourceType(sourceType); + sourceDataRepository.save(sourceData); + } catch (RuntimeException ex) { + log.error("Failed to import source data {}", catalogSourceData, ex); + } + } + + private static boolean isSourceTypeValid(SourceType sourceType) { + if (sourceType.getProducer() == null) { + log.warn("Catalog source-type {} does not have a vendor. " + + "Skipping importing this type", sourceType.getName()); + return false; + } + + if (sourceType.getModel() == null) { + log.warn("Catalog source-type {} does not have a model. " + + "Skipping importing this type", sourceType.getName()); + return false; + } + + if (sourceType.getCatalogVersion() == null) { + log.warn("Catalog source-type {} does not have a version. " + + "Skipping importing this type", sourceType.getName()); + return false; + } + return true; + } } diff --git a/src/main/java/org/radarbase/management/web/rest/util/HttpUtil.java b/src/main/java/org/radarbase/management/web/rest/util/HttpUtil.java deleted file mode 100644 index 3103c56cd..000000000 --- a/src/main/java/org/radarbase/management/web/rest/util/HttpUtil.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.radarbase.management.web.rest.util; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public final class HttpUtil { - - private static final Logger log = LoggerFactory.getLogger(HttpUtil.class); - - private HttpUtil() { - // utility class - } - - /** - * Checks whether given {@link URL} can be reachable. - * - * @return {@code true} if reachable, {@code false} otherwise - */ - public static boolean isReachable(URL urlServer) { - try { - HttpURLConnection urlConn = (HttpURLConnection) urlServer.openConnection(); - urlConn.setConnectTimeout(30000); //<- 30Seconds Timeout - urlConn.connect(); - return urlConn.getResponseCode() == 200; - } catch (IOException e) { - log.warn("Server {} is unreachable: {}", urlServer, e.getMessage()); - return false; - } - } - - -} From c2beaa619fe35b6541bc7b51b233f287585d28eb Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 8 May 2023 11:42:54 +0200 Subject: [PATCH 022/120] Bump gradle wrapper and dependencies Update dependencies --- build.gradle | 10 +++---- gradle.properties | 26 +++++++++--------- gradle/wrapper/gradle-wrapper.jar | Bin 61608 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 7 +++-- managementportal-client/build.gradle | 8 +++--- radar-auth/build.gradle | 2 +- .../org/radarbase/auth/jwks/JsonWebKey.kt | 2 +- 8 files changed, 29 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index 0e92bd0c2..d561288d8 100644 --- a/build.gradle +++ b/build.gradle @@ -16,13 +16,13 @@ buildscript { plugins { id 'application' id 'org.springframework.boot' version "${spring_boot_version}" - id "com.github.node-gradle.node" version "3.5.1" + id "com.github.node-gradle.node" version "3.6.0" id "io.spring.dependency-management" version "1.1.0" - id 'de.undercouch.download' version '5.3.1' apply false + id 'de.undercouch.download' version '5.4.0' apply false id "io.github.gradle-nexus.publish-plugin" version "1.3.0" id("com.github.ben-manes.versions") version "0.46.0" - id 'org.jetbrains.kotlin.jvm' version "1.8.10" - id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' apply false + id 'org.jetbrains.kotlin.jvm' version "1.8.21" + id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' apply false id 'org.jetbrains.dokka' version "1.8.10" } @@ -256,7 +256,7 @@ tasks.register('cleanResources', Delete) { } wrapper { - gradleVersion '8.0.2' + gradleVersion '8.1.1' } tasks.register('stage') { diff --git a/gradle.properties b/gradle.properties index 903daa2cd..0042761f1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,29 +5,29 @@ jhipster_server_version=7.9.3 hazelcast_version=5.2.0 hikaricp_version=5.0.1 liquibase_slf4j_version=4.1.0 -liquibase_version=4.17.2 -postgresql_version=42.5.4 +liquibase_version=4.21.1 +postgresql_version=42.6.0 springdoc_version=1.6.15 -spring_boot_version=2.7.9 -spring_framework_version=5.3.25 +spring_boot_version=2.7.10 +spring_framework_version=5.3.27 spring_data_version=2021.2.5 spring_session_version=2021.2.0 gatling_version=3.8.4 -mapstruct_version=1.5.3.Final -jackson_version=2.14.2 +mapstruct_version=1.5.5.Final +jackson_version=2.15.0 javax_xml_bind_version=2.3.3 javax_jaxb_core_version=2.3.0.1 -javax_jaxb_runtime_version=2.3.4 +javax_jaxb_runtime_version=2.3.8 javax_activation=1.1.1 mockito_version=4.8.1 -slf4j_version=2.0.6 -logback_version=1.4.6 -oauth_jwt_version=4.3.0 -junit_version=5.9.2 +slf4j_version=2.0.7 +logback_version=1.4.7 +oauth_jwt_version=4.4.0 +junit_version=5.9.3 okhttp_version=4.10.0 hsqldb_version=2.7.1 -coroutines_version=1.6.4 -ktor_version=2.2.4 +coroutines_version=1.7.0 +ktor_version=2.3.0 kotlin.code.style=official org.gradle.vfs.watch=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710deaf9f98673a68957ea02138b60d0a..c1962a79e29d3e0ab67b14947c167a862655af9b 100644 GIT binary patch delta 8979 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 F{TFmrHLU;u delta 8469 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::class) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy = if ("value" in element.jsonObject) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = if ("value" in element.jsonObject) { MPJsonWebKey.serializer() } else when (element.jsonObject["kty"]?.jsonPrimitive?.content) { "EC" -> ECDSAJsonWebKey.serializer() From b51ed8d85998de149f7bbd75d6e5c24f45e3993f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 8 May 2023 12:19:40 +0200 Subject: [PATCH 023/120] Fix Snyk issues --- .github/workflows/scheduled-snyk.yaml | 2 +- .github/workflows/snyk.yaml | 2 +- .snyk | 15 +++++++++++++++ build.gradle | 7 ++++++- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 .snyk diff --git a/.github/workflows/scheduled-snyk.yaml b/.github/workflows/scheduled-snyk.yaml index 370e0ec0d..c9307c788 100644 --- a/.github/workflows/scheduled-snyk.yaml +++ b/.github/workflows/scheduled-snyk.yaml @@ -33,7 +33,7 @@ jobs: run: > snyk test --all-projects - --configuration-matching="^compileClasspath$|^runtimeClasspath$" + --configuration-matching="^runtimeClasspath$" --org=radar-base --policy-path=.snyk --json-file-output=snyk.json diff --git a/.github/workflows/snyk.yaml b/.github/workflows/snyk.yaml index 747ec40fb..705d85df5 100644 --- a/.github/workflows/snyk.yaml +++ b/.github/workflows/snyk.yaml @@ -32,7 +32,7 @@ jobs: run: > snyk test --all-projects - --configuration-matching="^compileClasspath$|^runtimeClasspath$" + --configuration-matching="^runtimeClasspath$" --fail-on=upgradable --org=radar-base --policy-path=.snyk diff --git a/.snyk b/.snyk new file mode 100644 index 000000000..1b188be2e --- /dev/null +++ b/.snyk @@ -0,0 +1,15 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.25.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JAVA-ORGYAML-2806360: + - '*': + reason: Not using YAML for user-facing code + expires: 2024-05-07T10:09:27.027Z + created: 2023-05-08T10:09:27.030Z + SNYK-JAVA-ORGSPRINGFRAMEWORKBOOT-5441321: + - '*': + reason: Not hosting in CloudFoundry + expires: 2024-05-07T10:09:52.346Z + created: 2023-05-08T10:09:52.353Z +patch: {} diff --git a/build.gradle b/build.gradle index d561288d8..122b244c4 100644 --- a/build.gradle +++ b/build.gradle @@ -201,7 +201,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") { exclude module: 'spring-boot-starter-tomcat' } - implementation "org.springframework.boot:spring-boot-starter-undertow" + implementation ("org.springframework.boot:spring-boot-starter-undertow") { + // Fix vulnerabilities + implementation("io.undertow:undertow-websockets-jsr:2.2.24.Final") + implementation("io.undertow:undertow-servlet:2.2.24.Final") + implementation("io.undertow:undertow-core:2.2.24.Final") + } implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation("org.springframework:spring-context-support") implementation("org.springframework.session:spring-session-hazelcast") From 9b0e667bb2ee0a7b8c578867daada970484c6c81 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 10 May 2023 10:37:37 +0200 Subject: [PATCH 024/120] Fix Spring loading issues --- src/main/java/org/radarbase/management/service/AuthService.kt | 2 +- .../org/radarbase/management/web/rest/AccountResource.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index bbfb065bf..365eaf9db 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -76,7 +76,7 @@ open class AuthService { return oracle.referentsByScope(token, permission) } - fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { + open fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { role.mayBeGranted(permission) } } diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index 5c2e70ecd..715cabcb5 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -2,6 +2,7 @@ import io.micrometer.core.annotation.Timed; import org.radarbase.auth.authorization.Permission; +import org.radarbase.auth.token.DataRadarToken; import org.radarbase.auth.token.RadarToken; import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.User; @@ -97,7 +98,7 @@ public ResponseEntity login(HttpSession session) throws NotAuthorizedEx throw new NotAuthorizedException("Cannot login without credentials"); } log.debug("Logging in user to session with principal {}", token.getUsername()); - session.setAttribute(TOKEN_ATTRIBUTE, token); + session.setAttribute(TOKEN_ATTRIBUTE, DataRadarToken.Companion.copy(token)); return getAccount(); } From 17b29e5de310c0c96bb99612161132856c9dcc5f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 17 May 2023 13:52:51 +0200 Subject: [PATCH 025/120] Add token to Account resource --- .../radarbase/management/service/RoleService.java | 8 +++++--- .../radarbase/management/service/UserService.java | 5 ++--- .../radarbase/management/service/dto/UserDTO.java | 10 ++++++++++ .../management/web/rest/AccountResource.java | 12 +++++++----- .../management/web/rest/OAuthClientsResource.java | 8 +++----- .../management/web/rest/AccountResourceIntTest.java | 6 +++--- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/RoleService.java b/src/main/java/org/radarbase/management/service/RoleService.java index 42b3a06ce..da580a6ba 100644 --- a/src/main/java/org/radarbase/management/service/RoleService.java +++ b/src/main/java/org/radarbase/management/service/RoleService.java @@ -78,14 +78,16 @@ public RoleDTO save(RoleDTO roleDto) { */ @Transactional(readOnly = true) public List findAll() { - User currentUser = userService.getUserWithAuthorities(); - if (currentUser == null) { + Optional optUser = userService.getUserWithAuthorities(); + if (optUser.isEmpty()) { // return an empty list if we do not have a current user (e.g. with client credentials // oauth2 grant) return Collections.emptyList(); } + User currentUser = optUser.get(); List currentUserAuthorities = currentUser.getAuthorities().stream() - .map(Authority::getName).collect(Collectors.toList()); + .map(Authority::getName) + .collect(Collectors.toList()); if (currentUserAuthorities.contains(RoleAuthority.SYS_ADMIN.getAuthority())) { log.debug("Request to get all Roles"); diff --git a/src/main/java/org/radarbase/management/service/UserService.java b/src/main/java/org/radarbase/management/service/UserService.java index b6991b315..a8a62bbb0 100644 --- a/src/main/java/org/radarbase/management/service/UserService.java +++ b/src/main/java/org/radarbase/management/service/UserService.java @@ -359,9 +359,8 @@ public Optional getUserWithAuthoritiesByLogin(String login) { * @return the currently authenticated user, or null if no user is currently authenticated */ @Transactional(readOnly = true) - public User getUserWithAuthorities() { - return userRepository.findOneWithRolesByLogin(SecurityUtils.getCurrentUserLogin()) - .orElse(null); + public Optional getUserWithAuthorities() { + return userRepository.findOneWithRolesByLogin(SecurityUtils.getCurrentUserLogin()); } diff --git a/src/main/java/org/radarbase/management/service/dto/UserDTO.java b/src/main/java/org/radarbase/management/service/dto/UserDTO.java index d63d05ad1..8965981b3 100644 --- a/src/main/java/org/radarbase/management/service/dto/UserDTO.java +++ b/src/main/java/org/radarbase/management/service/dto/UserDTO.java @@ -45,6 +45,8 @@ public class UserDTO { private Set authorities; + private String accessToken; + public Long getId() { return id; } @@ -149,6 +151,14 @@ public void setRoles(Set roles) { this.roles = roles; } + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + @Override public String toString() { return "UserDTO{" diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index 715cabcb5..b1cc40ef1 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -33,7 +33,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import javax.validation.Valid; -import java.util.Optional; import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; import static org.radarbase.management.web.rest.errors.EntityName.USER; @@ -123,15 +122,18 @@ public ResponseEntity logout(HttpServletRequest request) { * GET /account : get the current user. * * @return the ResponseEntity with status 200 (OK) and the current user in body, or status 500 - * (Internal Server Error) if the user couldn't be returned + * (Internal Server Error) if the user couldn't be returned */ @GetMapping("/account") @Timed - public ResponseEntity getAccount() { - return Optional.ofNullable(userService.getUserWithAuthorities()) - .map(user -> new ResponseEntity<>(userMapper.userToUserDTO(user), HttpStatus.OK)) + public UserDTO getAccount(RadarToken radarToken) { + User currentUser = userService.getUserWithAuthorities() .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.FORBIDDEN, "Cannot get account without user", USER, ERR_ACCESS_DENIED)); + + UserDTO userDto = userMapper.userToUserDTO(currentUser); + userDto.setAccessToken(radarToken.getToken()); + return userDto; } /** diff --git a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java index df43e6217..244823add 100644 --- a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java @@ -196,11 +196,9 @@ public ResponseEntity getRefreshToken(@RequestParam String lo @RequestParam(value = "persistent", defaultValue = "false") Boolean persistent) throws NotAuthorizedException, URISyntaxException, MalformedURLException { authService.checkScope(SUBJECT_UPDATE); - User currentUser = userService.getUserWithAuthorities(); - if (currentUser == null) { - // We only allow this for actual logged in users for now, not for client_credentials - throw new AccessDeniedException("You must be a logged in user to access this resource"); - } + User currentUser = userService.getUserWithAuthorities() + // We only allow this for actual logged in users for now, not for client_credentials + .orElseThrow(() -> new AccessDeniedException("You must be a logged in user to access this resource")); // lookup the subject Subject subject = subjectService.findOneByLogin(login); diff --git a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java index 755783950..bd5b3042d 100644 --- a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java @@ -131,7 +131,7 @@ void testAuthenticatedUser() throws Exception { user.setEmail("john.doe@jhipster.com"); user.setLangKey("en"); user.setRoles(roles); - when(mockUserService.getUserWithAuthorities()).thenReturn(user); + when(mockUserService.getUserWithAuthorities()).thenReturn(Optional.of(user)); restUserMockMvc.perform(post("/api/login") .with(request -> { @@ -167,7 +167,7 @@ void testGetExistingAccount() throws Exception { user.setEmail("john.doe@jhipster.com"); user.setLangKey("en"); user.setRoles(roles); - when(mockUserService.getUserWithAuthorities()).thenReturn(user); + when(mockUserService.getUserWithAuthorities()).thenReturn(Optional.of(user)); restUserMockMvc.perform(get("/api/account") .accept(MediaType.APPLICATION_JSON)) @@ -184,7 +184,7 @@ void testGetExistingAccount() throws Exception { @Test void testGetUnknownAccount() throws Exception { - when(mockUserService.getUserWithAuthorities()).thenReturn(null); + when(mockUserService.getUserWithAuthorities()).thenReturn(Optional.empty()); restUserMockMvc.perform(get("/api/account") .accept(MediaType.APPLICATION_JSON)) From 0e84017ee44dd3cca65d97ffddbf2893bd720ea1 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 13:32:13 +0200 Subject: [PATCH 026/120] Fix client credentials authorization --- .../org/radarbase/auth/authorization/MPAuthorizationOracle.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt index 25e1e53d0..fec7a02d8 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt @@ -53,6 +53,9 @@ class MPAuthorizationOracle( identity: RadarToken, permission: Permission ): AuthorityReferenceSet { + if (identity.isClientCredentials) { + return AuthorityReferenceSet(global = true) + } var global = false val organizations = mutableSetOf() val projects = mutableSetOf() From 3d2c2f4892a642f9b25d23a116088dc5ace998d6 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 13:32:13 +0200 Subject: [PATCH 027/120] Fix client credentials authorization --- .../org/radarbase/auth/authorization/MPAuthorizationOracle.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt index 25e1e53d0..fec7a02d8 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt @@ -53,6 +53,9 @@ class MPAuthorizationOracle( identity: RadarToken, permission: Permission ): AuthorityReferenceSet { + if (identity.isClientCredentials) { + return AuthorityReferenceSet(global = true) + } var global = false val organizations = mutableSetOf() val projects = mutableSetOf() From 6c1f232abb4d50248c6eb56fdac8c0df7a83630f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 13:35:19 +0200 Subject: [PATCH 028/120] Add token to account resource --- .../management/client/MPOAuthClient.kt | 1 - .../ManagementPortalSecurityConfigLoader.java | 3 ++- .../management/service/mapper/UserMapper.java | 2 ++ .../management/web/rest/AccountResource.java | 6 +++--- .../service/OAuthClientServiceTestUtil.java | 19 +++++++++++-------- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt index 17a1c338a..4a3609955 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPOAuthClient.kt @@ -1,6 +1,5 @@ package org.radarbase.management.client -import kotlinx.serialization.EncodeDefault import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java b/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java index f23d0d515..aa1f15116 100644 --- a/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java +++ b/src/main/java/org/radarbase/management/config/ManagementPortalSecurityConfigLoader.java @@ -83,7 +83,8 @@ public void loadFrontendOauthClient() { details.setClientSecret(null); details.setAccessTokenValiditySeconds(frontend.getAccessTokenValiditySeconds()); details.setRefreshTokenValiditySeconds(frontend.getRefreshTokenValiditySeconds()); - details.setResourceIds(Collections.singletonList("res_ManagementPortal")); + details.setResourceIds(List.of("res_ManagementPortal", "res_appconfig", "res_upload", + "res_restAuthorizer")); details.setAuthorizedGrantTypes(Arrays.asList("password", "refresh_token", "authorization_code")); details.setAdditionalInformation(Collections.singletonMap("protected", Boolean.TRUE)); diff --git a/src/main/java/org/radarbase/management/service/mapper/UserMapper.java b/src/main/java/org/radarbase/management/service/mapper/UserMapper.java index 3183e9a5e..69991ec54 100644 --- a/src/main/java/org/radarbase/management/service/mapper/UserMapper.java +++ b/src/main/java/org/radarbase/management/service/mapper/UserMapper.java @@ -22,12 +22,14 @@ public interface UserMapper { @Mapping(target = "createdDate", ignore = true) @Mapping(target = "lastModifiedBy", ignore = true) @Mapping(target = "lastModifiedDate", ignore = true) + @Mapping(target = "accessToken", ignore = true) UserDTO userToUserDTO(User user); @Mapping(target = "createdBy", ignore = true) @Mapping(target = "createdDate", ignore = true) @Mapping(target = "lastModifiedBy", ignore = true) @Mapping(target = "lastModifiedDate", ignore = true) + @Mapping(target = "accessToken", ignore = true) UserDTO userToUserDTONoProvenance(User user); @Mapping(target = "activationKey", ignore = true) diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index b1cc40ef1..1ca2e3488 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -92,7 +92,7 @@ public ResponseEntity activateAccount(@RequestParam(value = "key") Strin */ @PostMapping("/login") @Timed - public ResponseEntity login(HttpSession session) throws NotAuthorizedException { + public UserDTO login(HttpSession session) throws NotAuthorizedException { if (token == null) { throw new NotAuthorizedException("Cannot login without credentials"); } @@ -126,13 +126,13 @@ public ResponseEntity logout(HttpServletRequest request) { */ @GetMapping("/account") @Timed - public UserDTO getAccount(RadarToken radarToken) { + public UserDTO getAccount() { User currentUser = userService.getUserWithAuthorities() .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.FORBIDDEN, "Cannot get account without user", USER, ERR_ACCESS_DENIED)); UserDTO userDto = userMapper.userToUserDTO(currentUser); - userDto.setAccessToken(radarToken.getToken()); + userDto.setAccessToken(token.getToken()); return userDto; } diff --git a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java index 01ff482f8..08cd5e726 100644 --- a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java +++ b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java @@ -4,6 +4,10 @@ import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -26,16 +30,15 @@ public static ClientDetailsDTO createClient() { ClientDetailsDTO result = new ClientDetailsDTO(); result.setClientId("TEST_CLIENT"); result.setClientSecret("TEST_SECRET"); - result.setScope(Arrays.asList("scope-1", "scope-2").stream().collect(Collectors.toSet())); - result.setResourceIds(Arrays.asList("res-1", "res-2").stream().collect(Collectors.toSet())); - result.setAutoApproveScopes(Arrays.asList("scope-1").stream().collect(Collectors.toSet())); - result.setAuthorizedGrantTypes(Arrays.asList("password", "refresh_token", - "authorization_code").stream().collect(Collectors.toSet())); + result.setScope(Set.of("scope-1", "scope-2")); + result.setResourceIds(Set.of("res-1", "res-2")); + result.setAutoApproveScopes(Set.of("scope-1")); + result.setAuthorizedGrantTypes(Set.of("password", "refresh_token", + "authorization_code")); result.setAccessTokenValiditySeconds(3600L); result.setRefreshTokenValiditySeconds(7200L); - result.setAuthorities(Arrays.asList("AUTHORITY-1").stream().collect(Collectors.toSet())); - result.setAdditionalInformation(new HashMap<>()); - result.getAdditionalInformation().put("dynamic_registration", "true"); + result.setAuthorities(Set.of("AUTHORITY-1")); + result.setAdditionalInformation(Map.of("dynamic_registration", "true")); return result; } } From f00aa74c7b06b3e854c2de5a943e4b7c493b593a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 14:50:31 +0200 Subject: [PATCH 029/120] Fix client ID and login details --- .../radarbase/auth/token/DataRadarToken.kt | 31 ++++++++------- .../management/security/SecurityUtils.java | 39 +++++++++---------- .../management/service/UserService.java | 10 ++++- .../management/web/rest/AccountResource.java | 6 +-- .../management/web/rest/SubjectResource.java | 3 +- .../security/SecurityUtilsUnitTest.java | 2 +- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt index 534afe079..7a2392ee7 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/token/DataRadarToken.kt @@ -85,22 +85,25 @@ data class DataRadarToken( */ override val clientId: String? = null, ): RadarToken, Serializable { + constructor(radarToken: RadarToken) : this( + roles = radarToken.roles, + scopes = radarToken.scopes, + sources = radarToken.sources, + grantType = radarToken.grantType, + subject = radarToken.subject, + username = radarToken.username, + issuedAt = radarToken.issuedAt, + expiresAt = radarToken.expiresAt, + audience = radarToken.audience, + token = radarToken.token, + issuer = radarToken.issuer, + type = radarToken.type, + clientId = radarToken.clientId, + ) + override fun copyWithRoles(roles: Set): DataRadarToken = copy(roles = roles) companion object { - fun RadarToken.copy(): DataRadarToken = DataRadarToken( - roles = roles, - scopes = scopes, - sources = sources, - grantType = grantType, - subject = subject, - username = username, - issuedAt = issuedAt, - expiresAt = expiresAt, - audience = audience, - token = token, - issuer = issuer, - type = type, - ) + fun RadarToken.toDataRadarToken(): DataRadarToken = DataRadarToken(this) } } diff --git a/src/main/java/org/radarbase/management/security/SecurityUtils.java b/src/main/java/org/radarbase/management/security/SecurityUtils.java index 3c498df87..ddd20113e 100644 --- a/src/main/java/org/radarbase/management/security/SecurityUtils.java +++ b/src/main/java/org/radarbase/management/security/SecurityUtils.java @@ -5,6 +5,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import java.util.Optional; + /** * Utility class for Spring Security. */ @@ -15,9 +17,9 @@ private SecurityUtils() { /** * Get the login of the current user. * - * @return the login of the current user + * @return the login of the current user if present */ - public static String getCurrentUserLogin() { + public static Optional getCurrentUserLogin() { SecurityContext securityContext = SecurityContextHolder.getContext(); return getUserName(securityContext.getAuthentication()); } @@ -26,24 +28,21 @@ public static String getCurrentUserLogin() { * Get the user name contianed in an Authentication object. * * @param authentication context authentication - * @return user name or {@code null} if unknown. + * @return user name if present */ - public static String getUserName(Authentication authentication) { - if (authentication == null) { - return null; - } - - Object principal = authentication.getPrincipal(); - if (principal == null) { - return null; - } else if (principal instanceof UserDetails) { - return ((UserDetails) authentication.getPrincipal()).getUsername(); - } else if (principal instanceof String) { - return (String) authentication.getPrincipal(); - } else if (principal instanceof Authentication) { - return ((Authentication)principal).getName(); - } else { - return null; - } + public static Optional getUserName(Authentication authentication) { + return Optional.ofNullable(authentication) + .map(Authentication::getPrincipal) + .map(principal -> { + if (principal instanceof UserDetails) { + return ((UserDetails) authentication.getPrincipal()).getUsername(); + } else if (principal instanceof String) { + return (String) authentication.getPrincipal(); + } else if (principal instanceof Authentication) { + return ((Authentication)principal).getName(); + } else { + return null; + } + }); } } diff --git a/src/main/java/org/radarbase/management/service/UserService.java b/src/main/java/org/radarbase/management/service/UserService.java index a8a62bbb0..53271fdf0 100644 --- a/src/main/java/org/radarbase/management/service/UserService.java +++ b/src/main/java/org/radarbase/management/service/UserService.java @@ -13,13 +13,16 @@ import org.radarbase.management.service.dto.UserDTO; import org.radarbase.management.service.mapper.UserMapper; import org.radarbase.management.web.rest.errors.ConflictException; +import org.radarbase.management.web.rest.errors.InvalidRequestException; import org.radarbase.management.web.rest.errors.NotFoundException; +import org.radarbase.management.web.rest.errors.RadarWebApplicationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -315,7 +318,9 @@ public void deleteUser(String login) { * @param password the new password */ public void changePassword(String password) { - changePassword(SecurityUtils.getCurrentUserLogin(), password); + String currentUser = SecurityUtils.getCurrentUserLogin() + .orElseThrow(() -> new InvalidRequestException("Cannot change password of unknown user", null, ERR_ENTITY_NOT_FOUND)); + changePassword(currentUser, password); } /** @@ -360,7 +365,8 @@ public Optional getUserWithAuthoritiesByLogin(String login) { */ @Transactional(readOnly = true) public Optional getUserWithAuthorities() { - return userRepository.findOneWithRolesByLogin(SecurityUtils.getCurrentUserLogin()); + return SecurityUtils.getCurrentUserLogin() + .flatMap(currentUser -> userRepository.findOneWithRolesByLogin(currentUser)); } diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index 1ca2e3488..0f3b670f1 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -97,7 +97,7 @@ public UserDTO login(HttpSession session) throws NotAuthorizedException { throw new NotAuthorizedException("Cannot login without credentials"); } log.debug("Logging in user to session with principal {}", token.getUsername()); - session.setAttribute(TOKEN_ATTRIBUTE, DataRadarToken.Companion.copy(token)); + session.setAttribute(TOKEN_ATTRIBUTE, new DataRadarToken(token)); return getAccount(); } @@ -121,14 +121,14 @@ public ResponseEntity logout(HttpServletRequest request) { /** * GET /account : get the current user. * - * @return the ResponseEntity with status 200 (OK) and the current user in body, or status 500 + * @return the ResponseEntity with status 200 (OK) and the current user in body, or status 401 * (Internal Server Error) if the user couldn't be returned */ @GetMapping("/account") @Timed public UserDTO getAccount() { User currentUser = userService.getUserWithAuthorities() - .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.FORBIDDEN, + .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.UNAUTHORIZED, "Cannot get account without user", USER, ERR_ACCESS_DENIED)); UserDTO userDto = userMapper.userToUserDTO(currentUser); diff --git a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java index 4ad8d31e8..d405a8c14 100644 --- a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java @@ -210,7 +210,8 @@ public ResponseEntity discontinueSubject(@RequestBody SubjectDTO sub // In principle this is already captured by the PostUpdate event listener, adding this // event just makes it more clear a subject was discontinued. - eventRepository.add(new AuditEvent(SecurityUtils.getCurrentUserLogin(), + eventRepository.add(new AuditEvent( + SecurityUtils.getCurrentUserLogin().orElse(null), "SUBJECT_DISCONTINUE", "subject_login=" + subjectDto.getLogin())); SubjectDTO result = subjectService.discontinueSubject(subjectDto); return ResponseEntity.ok() diff --git a/src/test/java/org/radarbase/management/security/SecurityUtilsUnitTest.java b/src/test/java/org/radarbase/management/security/SecurityUtilsUnitTest.java index 94252bab3..3e1008354 100644 --- a/src/test/java/org/radarbase/management/security/SecurityUtilsUnitTest.java +++ b/src/test/java/org/radarbase/management/security/SecurityUtilsUnitTest.java @@ -20,7 +20,7 @@ void testGetCurrentUserLogin() { securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("admin", "admin")); SecurityContextHolder.setContext(securityContext); - String login = SecurityUtils.getCurrentUserLogin(); + String login = SecurityUtils.getCurrentUserLogin().orElse(null); assertThat(login).isEqualTo("admin"); } } From 052a3c6860e8c25e9dd8a990e208ccfbbd85e9b1 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 23 May 2023 14:16:16 +0200 Subject: [PATCH 030/120] Code style fixes --- .../management/service/UserService.java | 6 ++-- .../management/web/rest/AccountResource.java | 4 +-- .../web/rest/OAuthClientsResource.java | 3 +- .../service/OAuthClientServiceTestUtil.java | 11 +++--- .../web/rest/OAuthClientsResourceIntTest.java | 35 +++++++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/radarbase/management/service/UserService.java b/src/main/java/org/radarbase/management/service/UserService.java index 53271fdf0..5968685b2 100644 --- a/src/main/java/org/radarbase/management/service/UserService.java +++ b/src/main/java/org/radarbase/management/service/UserService.java @@ -15,14 +15,12 @@ import org.radarbase.management.web.rest.errors.ConflictException; import org.radarbase.management.web.rest.errors.InvalidRequestException; import org.radarbase.management.web.rest.errors.NotFoundException; -import org.radarbase.management.web.rest.errors.RadarWebApplicationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -319,7 +317,9 @@ public void deleteUser(String login) { */ public void changePassword(String password) { String currentUser = SecurityUtils.getCurrentUserLogin() - .orElseThrow(() -> new InvalidRequestException("Cannot change password of unknown user", null, ERR_ENTITY_NOT_FOUND)); + .orElseThrow(() -> new InvalidRequestException( + "Cannot change password of unknown user", null, + ERR_ENTITY_NOT_FOUND)); changePassword(currentUser, password); } diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index 0f3b670f1..2522cd972 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -122,13 +122,13 @@ public ResponseEntity logout(HttpServletRequest request) { * GET /account : get the current user. * * @return the ResponseEntity with status 200 (OK) and the current user in body, or status 401 - * (Internal Server Error) if the user couldn't be returned + * (Internal Server Error) if the user couldn't be returned */ @GetMapping("/account") @Timed public UserDTO getAccount() { User currentUser = userService.getUserWithAuthorities() - .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.UNAUTHORIZED, + .orElseThrow(() -> new RadarWebApplicationException(HttpStatus.FORBIDDEN, "Cannot get account without user", USER, ERR_ACCESS_DENIED)); UserDTO userDto = userMapper.userToUserDTO(currentUser); diff --git a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java index 244823add..41a5cf880 100644 --- a/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java +++ b/src/main/java/org/radarbase/management/web/rest/OAuthClientsResource.java @@ -198,7 +198,8 @@ public ResponseEntity getRefreshToken(@RequestParam String lo authService.checkScope(SUBJECT_UPDATE); User currentUser = userService.getUserWithAuthorities() // We only allow this for actual logged in users for now, not for client_credentials - .orElseThrow(() -> new AccessDeniedException("You must be a logged in user to access this resource")); + .orElseThrow(() -> new AccessDeniedException( + "You must be a logged in user to access this resource")); // lookup the subject Subject subject = subjectService.findOneByLogin(login); diff --git a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java index 08cd5e726..0000c425b 100644 --- a/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java +++ b/src/test/java/org/radarbase/management/service/OAuthClientServiceTestUtil.java @@ -2,13 +2,8 @@ import org.radarbase.management.service.dto.ClientDetailsDTO; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; +import java.util.LinkedHashMap; import java.util.Set; -import java.util.stream.Collectors; /** * Test class for the OAuthClientService class. @@ -38,7 +33,9 @@ public static ClientDetailsDTO createClient() { result.setAccessTokenValiditySeconds(3600L); result.setRefreshTokenValiditySeconds(7200L); result.setAuthorities(Set.of("AUTHORITY-1")); - result.setAdditionalInformation(Map.of("dynamic_registration", "true")); + var additionalInfo = new LinkedHashMap(); + additionalInfo.put("dynamic_registration", "true"); + result.setAdditionalInformation(additionalInfo); return result; } } diff --git a/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java index 077574a72..8e1e346e3 100644 --- a/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/OAuthClientsResourceIntTest.java @@ -32,7 +32,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; import static org.radarbase.management.service.OAuthClientServiceTestUtil.createClient; @@ -138,21 +138,24 @@ void createAndFetchOAuthClient() throws Exception { .getAccessTokenValiditySeconds().intValue()))) .andExpect(jsonPath("$.refreshTokenValiditySeconds").value(equalTo(details .getRefreshTokenValiditySeconds().intValue()))) - .andExpect(jsonPath("$.scope").value(contains(details.getScope().toArray()))) - .andExpect(jsonPath("$.autoApproveScopes").value(contains(details - .getAutoApproveScopes().toArray()))) - .andExpect(jsonPath("$.authorizedGrantTypes").value(contains(details - .getAuthorizedGrantTypes().toArray()))) + .andExpect(jsonPath("$.scope").value(containsInAnyOrder( + details.getScope().toArray()))) + .andExpect(jsonPath("$.autoApproveScopes").value(containsInAnyOrder( + details.getAutoApproveScopes().toArray()))) + .andExpect(jsonPath("$.authorizedGrantTypes").value(containsInAnyOrder( + details.getAuthorizedGrantTypes().toArray()))) .andExpect(jsonPath("$.authorities").value( - contains(details.getAuthorities().toArray()))); + containsInAnyOrder(details.getAuthorities().toArray()))); - ClientDetails testDetails = clientDetailsList.stream().filter( - d -> d.getClientId().equals(details.getClientId())).findFirst().get(); + ClientDetails testDetails = clientDetailsList.stream() + .filter(d -> d.getClientId().equals(details.getClientId())) + .findFirst() + .orElseThrow(); assertThat(testDetails.getClientSecret()).startsWith("$2a$10$"); - assertThat(testDetails.getScope()).containsExactlyElementsOf(details.getScope()); - assertThat(testDetails.getResourceIds()).containsExactlyElementsOf( + assertThat(testDetails.getScope()).containsExactlyInAnyOrderElementsOf(details.getScope()); + assertThat(testDetails.getResourceIds()).containsExactlyInAnyOrderElementsOf( details.getResourceIds()); - assertThat(testDetails.getAuthorizedGrantTypes()).containsExactlyElementsOf( + assertThat(testDetails.getAuthorizedGrantTypes()).containsExactlyInAnyOrderElementsOf( details.getAuthorizedGrantTypes()); details.getAutoApproveScopes().forEach(scope -> assertThat(testDetails.isAutoApprove(scope)).isTrue()); @@ -161,7 +164,7 @@ void createAndFetchOAuthClient() throws Exception { assertThat(testDetails.getRefreshTokenValiditySeconds()).isEqualTo( details.getRefreshTokenValiditySeconds().intValue()); assertThat(testDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority)) - .containsExactlyElementsOf(details.getAuthorities()); + .containsExactlyInAnyOrderElementsOf(details.getAuthorities()); assertThat(testDetails.getAdditionalInformation()).containsAllEntriesOf( details.getAdditionalInformation() ); @@ -189,8 +192,10 @@ void updateOAuthClient() throws Exception { // fetch the client clientDetailsList = clientDetailsService.listClientDetails(); assertThat(clientDetailsList).hasSize(databaseSizeBeforeCreate + 1); - ClientDetails testDetails = clientDetailsList.stream().filter( - d -> d.getClientId().equals(details.getClientId())).findFirst().get(); + ClientDetails testDetails = clientDetailsList.stream() + .filter(d -> d.getClientId().equals(details.getClientId())) + .findFirst() + .orElseThrow(); assertThat(testDetails.getRefreshTokenValiditySeconds()).isEqualTo(20); } From f969c6fc95d0f8249c90088ff85f7fd09ee4f32c Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 23 May 2023 16:52:04 +0200 Subject: [PATCH 031/120] Ensure that authorization flow works by adding temporary token in password flow. --- .../radarbase/management/domain/Subject.java | 4 +- .../security/DomainUserDetailsService.java | 106 ++++++++-- .../security/JwtAuthenticationFilter.java | 184 +++++++++++------- 3 files changed, 202 insertions(+), 92 deletions(-) diff --git a/src/main/java/org/radarbase/management/domain/Subject.java b/src/main/java/org/radarbase/management/domain/Subject.java index 1efe04e7a..c05b743f9 100644 --- a/src/main/java/org/radarbase/management/domain/Subject.java +++ b/src/main/java/org/radarbase/management/domain/Subject.java @@ -252,9 +252,7 @@ public Optional getActiveProject() { public Optional getAssociatedProject() { return this.getUser().getRoles().stream() .filter(r -> PARTICIPANT_TYPES.contains(r.getAuthority().getName())) - .sorted(Comparator.comparing(r -> r.getAuthority().getName()) - .reversed()) - .findFirst() + .max(Comparator.comparing(r -> r.getAuthority().getName())) .map(Role::getProject); } diff --git a/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java b/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java index 36033443d..3c626d0c9 100644 --- a/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java +++ b/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java @@ -1,9 +1,9 @@ package org.radarbase.management.security; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.stream.Collectors; +import org.radarbase.auth.authorization.AuthorityReference; +import org.radarbase.auth.authorization.Permission; +import org.radarbase.auth.authorization.RoleAuthority; +import org.radarbase.auth.token.DataRadarToken; import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.slf4j.Logger; @@ -16,6 +16,23 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static org.radarbase.auth.authorization.MPAuthorizationOracle.allowedRoles; +import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; +import static org.radarbase.management.security.JwtAuthenticationFilter.userAuthorities; +import static org.radarbase.management.security.jwt.ManagementPortalJwtAccessTokenConverter.RES_MANAGEMENT_PORTAL; + /** * Authenticate a user from the database. */ @@ -26,8 +43,13 @@ public class DomainUserDetailsService implements UserDetailsService { private final UserRepository userRepository; - public DomainUserDetailsService(UserRepository userRepository) { + private final HttpServletRequest httpRequest; + + public DomainUserDetailsService( + UserRepository userRepository, + @Nullable HttpServletRequest httpRequest) { this.userRepository = userRepository; + this.httpRequest = httpRequest; } @Override @@ -35,19 +57,65 @@ public DomainUserDetailsService(UserRepository userRepository) { public UserDetails loadUserByUsername(final String login) { log.debug("Authenticating {}", login); String lowercaseLogin = login.toLowerCase(Locale.ENGLISH); - Optional userFromDatabase = userRepository.findOneWithRolesByLogin(lowercaseLogin); - return userFromDatabase.map(user -> { - if (!user.getActivated()) { - throw new UserNotActivatedException("User " + lowercaseLogin - + " was not activated"); - } - List grantedAuthorities = user.getAuthorities().stream() - .map(authority -> new SimpleGrantedAuthority(authority.getName())) - .collect(Collectors.toList()); - return new org.springframework.security.core.userdetails.User(lowercaseLogin, - user.getPassword(), - grantedAuthorities); - }).orElseThrow(() -> new UsernameNotFoundException("User " + lowercaseLogin + " was not " - + "found in the database")); + User user = userRepository.findOneWithRolesByLogin(lowercaseLogin) + .orElseThrow(() -> new UsernameNotFoundException( + "User " + lowercaseLogin + " was not found in the database")); + if (!user.getActivated()) { + throw new UserNotActivatedException("User " + lowercaseLogin + + " was not activated"); + } + addTokenToSession(user); + + List grantedAuthorities = user.getAuthorities().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getName())) + .collect(Collectors.toList()); + + return new org.springframework.security.core.userdetails.User( + lowercaseLogin, + user.getPassword(), + grantedAuthorities); + } + + private void addTokenToSession(User user) { + if (httpRequest == null) { + return; + } + var roles = userAuthorities(user); + + var roleAuthorities = roles.stream() + .map(AuthorityReference::getRole) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(RoleAuthority.class))); + + var scopes = Arrays.stream(Permission.values()) + .filter(permission -> !Collections.disjoint( + allowedRoles(permission), + roleAuthorities)) + .map(Permission::scope) + .collect(Collectors.toCollection(TreeSet::new)); + + String subject = roleAuthorities.contains(RoleAuthority.PARTICIPANT) + ? user.getLogin() + : null; + + var token = new DataRadarToken( + roles, + scopes, + List.of(), + "password", + subject, + user.getLogin(), + Instant.now(), + Instant.MAX, + List.of(RES_MANAGEMENT_PORTAL), + null, + null, + "session", + null); + + httpRequest.setAttribute(TOKEN_ATTRIBUTE, token); + HttpSession session = httpRequest.getSession(false); + if (session != null) { + session.setAttribute(TOKEN_ATTRIBUTE, token); + } } } diff --git a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java index 95563b9a2..c3d9051fa 100644 --- a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java @@ -1,9 +1,10 @@ package org.radarbase.management.security; import org.radarbase.auth.authentication.TokenValidator; -import org.radarbase.auth.exception.TokenValidationException; import org.radarbase.auth.authorization.AuthorityReference; +import org.radarbase.auth.exception.TokenValidationException; import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,8 +19,6 @@ import javax.annotation.Nonnull; import javax.servlet.FilterChain; import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @@ -27,6 +26,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -35,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); public static final String AUTHORIZATION_BEARER_HEADER = "Bearer"; + private final TokenValidator validator; private final AuthenticationManager authenticationManager; public static final String TOKEN_ATTRIBUTE = "jwt"; @@ -42,6 +43,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserRepository userRepository; private final boolean isOptional; + /** + * Authority references for given user. The user should have its roles mapped + * from the database. + * @param user user to get authority references from. + * @return set of authority references. + */ + public static Set userAuthorities(User user) { + return user.getRoles().stream() + .map(role -> { + var auth = role.getRole(); + return switch (role.getRole().getScope()) { + case GLOBAL -> new AuthorityReference(auth); + case ORGANIZATION -> new AuthorityReference(auth, + role.getOrganization().getName()); + case PROJECT -> new AuthorityReference(auth, + role.getProject().getProjectName()); + }; + }) + .collect(Collectors.toSet()); + } + /** * Authentication filter using given validator. Authentication is mandatory. * @param validator validates the JWT token. @@ -96,68 +118,38 @@ protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, } HttpSession session = httpRequest.getSession(false); + + String stringToken = tokenFromHeader(httpRequest); RadarToken token = null; - if (session != null) { - token = (RadarToken) session.getAttribute(TOKEN_ATTRIBUTE); - } - if (token == null) { + String exMessage = "No token provided"; + if (stringToken != null) { try { - token = validator.validateBlocking(getToken(httpRequest, httpResponse)); + token = validator.validateBlocking(stringToken); + logger.debug("Using token from header"); } catch (TokenValidationException ex) { - if (isOptional) { - logger.debug("Skipping optional token: {}", ex.getMessage()); - } else { - logger.error("Failed to validate token: {}", ex.getMessage()); - httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, - AUTHORIZATION_BEARER_HEADER); - httpResponse.getOutputStream().print( - "{\"error\": \"" + "Unauthorized" + ",\n" - + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED - + "\",\n" - + "\"message\": \"" + ex.getMessage() + "\",\n" - + "\"path\": \"" + httpRequest.getRequestURI() + "\n" - + "\"}"); - return; - } + exMessage = ex.getMessage(); + logger.info("Failed to validate token from session: {}", exMessage); } - } else if (!token.isClientCredentials()) { - var user = userRepository.findOneByLogin(token.getUsername()); - if (user.isPresent()) { - var roles = user.get().getRoles().stream() - .map(role -> { - var auth = role.getRole(); - return switch (role.getRole().getScope()) { - case GLOBAL -> new AuthorityReference(auth); - case ORGANIZATION -> new AuthorityReference(auth, - role.getOrganization().getName()); - case PROJECT -> new AuthorityReference(auth, - role.getProject().getProjectName()); - }; - }) - .collect(Collectors.toSet()); - token = token.copyWithRoles(roles); - } else { - session.removeAttribute(TOKEN_ATTRIBUTE); - httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); - httpResponse.getOutputStream().print( - "{\"error\": \"" + "Unauthorized" + ",\n" - + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + ",\n" - + "\"message\": \"User not found\",\n" - + "\"path\": \"" + httpRequest.getRequestURI() + "\n" - + "\"}"); - return; + } + + if (token == null) { + token = tokenFromSession(session); + if (token != null) { + logger.debug("Using token from session"); } } - if (token != null) { - httpRequest.setAttribute(TOKEN_ATTRIBUTE, token); - RadarAuthentication authentication = new RadarAuthentication(token); - authenticationManager.authenticate(authentication); - SecurityContextHolder.getContext().setAuthentication(authentication); + if (validateToken(token, httpRequest, httpResponse, session, exMessage)) { + chain.doFilter(httpRequest, httpResponse); + } + } + + private RadarToken tokenFromSession(HttpSession session) { + if (session != null) { + return (RadarToken) session.getAttribute(TOKEN_ATTRIBUTE); + } else { + return null; } - chain.doFilter(httpRequest, httpResponse); } @Override @@ -174,21 +166,73 @@ protected boolean shouldNotFilter(@Nonnull HttpServletRequest httpRequest) { return false; } - private String getToken(ServletRequest request, ServletResponse response) { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse res = (HttpServletResponse) response; - String authorizationHeader = req.getHeader(HttpHeaders.AUTHORIZATION); - - // Check if the HTTP Authorization header is present and formatted correctly - if (authorizationHeader == null || !authorizationHeader + private String tokenFromHeader(HttpServletRequest httpRequest) { + String authorizationHeader = httpRequest.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader != null && authorizationHeader .startsWith(AUTHORIZATION_BEARER_HEADER)) { - res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - res.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); - throw new TokenValidationException("No " + AUTHORIZATION_BEARER_HEADER - + " Authorization token present in the request to " + req.getServletPath()); + return authorizationHeader.substring(AUTHORIZATION_BEARER_HEADER.length()).trim(); + } else { + return null; } + } - // Extract the token from the HTTP Authorization header - return authorizationHeader.substring(AUTHORIZATION_BEARER_HEADER.length()).trim(); + private boolean validateToken(RadarToken token, HttpServletRequest httpRequest, + HttpServletResponse httpResponse, HttpSession session, String exMessage) + throws IOException { + if (token == null) { + if (isOptional) { + logger.debug("Skipping optional token"); + return true; + } else { + logger.error("Unauthorized - no valid token provided"); + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, + AUTHORIZATION_BEARER_HEADER); + httpResponse.getOutputStream().print( + "{\"error\": \"" + "Unauthorized" + ",\n" + + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + + "\",\n" + + "\"message\": \"" + exMessage + "\",\n" + + "\"path\": \"" + httpRequest.getRequestURI() + "\n" + + "\"}"); + return false; + } + } + + RadarToken updatedToken = checkUser(token, httpRequest, httpResponse, session); + if (updatedToken == null) { + return false; + } + + httpRequest.setAttribute(TOKEN_ATTRIBUTE, updatedToken); + RadarAuthentication authentication = new RadarAuthentication(updatedToken); + authenticationManager.authenticate(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return true; + } + + private RadarToken checkUser(RadarToken token, HttpServletRequest httpRequest, + HttpServletResponse httpResponse, HttpSession session) throws IOException { + String userName = token.getUsername(); + if (userName == null) { + return token; + } + var user = userRepository.findOneByLogin(userName); + if (user.isPresent()) { + return token.copyWithRoles(userAuthorities(user.get())); + } else { + if (session != null) { + session.removeAttribute(TOKEN_ATTRIBUTE); + } + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); + httpResponse.getOutputStream().print( + "{\"error\": \"" + "Unauthorized" + ",\n" + + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + ",\n" + + "\"message\": \"User not found\",\n" + + "\"path\": \"" + httpRequest.getRequestURI() + "\n" + + "\"}"); + return null; + } } } From 07ae6af2c0c89f5b413327a40d09300d447d5f0e Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 24 May 2023 10:16:08 +0200 Subject: [PATCH 032/120] multiple roleauthority check and unavailable project is null --- .../auth/authorization/AuthorizationOracle.kt | 3 +++ .../auth/authorization/EntityRelationService.kt | 7 +++++-- .../auth/authorization/MPAuthorizationOracle.kt | 10 +++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt index 2233c0329..b2b148058 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/AuthorizationOracle.kt @@ -47,6 +47,9 @@ interface AuthorizationOracle { permission: Permission ): AuthorityReferenceSet + fun Collection.mayBeGranted(permission: Permission): Boolean = + any { it.mayBeGranted(permission) } + fun RoleAuthority.mayBeGranted(permission: Permission): Boolean } diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt index 8978b812d..4035aec70 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/EntityRelationService.kt @@ -2,8 +2,11 @@ package org.radarbase.auth.authorization /** Service to determine the relationship between entities. */ interface EntityRelationService { - /** From a [project] name, return an organization name. */ - suspend fun findOrganizationOfProject(project: String): String + /** + * From a [project] name, return an organization name. + * @return organization name if found, null otherwise. + */ + suspend fun findOrganizationOfProject(project: String): String? /** Whether given [organization] name has a [project] with given name. */ suspend fun organizationContainsProject(organization: String, project: String): Boolean = diff --git a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt index fec7a02d8..35601eeeb 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/authorization/MPAuthorizationOracle.kt @@ -85,13 +85,13 @@ class MPAuthorizationOracle( ) } - /** - * Check if this role has may have given permission associated with it. - * @param permission the permission to check - * @return true if this role has given permission associated with it, false otherwise - */ override fun RoleAuthority.mayBeGranted(permission: Permission) = this in allowedRoles(permission) + override fun Collection.mayBeGranted(permission: Permission): Boolean { + val allowedRoles = allowedRoles(permission) + return any { it in allowedRoles } + } + /** * Whether the current role from [identity] has [permission] over given [entity] in * [entityScope] in any way. From 926e5100c3c1663ca5e490f07d1a1e83af038089 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 24 May 2023 10:18:00 +0200 Subject: [PATCH 033/120] Misc code cleanup --- .../config/AuthorizationConfiguration.kt | 26 +++++++++++++++++ .../config/OAuth2LoginUiWebConfig.java | 29 ++++++++++--------- .../filters/SubjectSpecification.java | 4 +-- .../repository/filters/UserFilter.java | 5 ++-- .../security/ClaimsTokenEnhancer.java | 17 +++++++---- .../management/service/AuthService.kt | 24 ++++----------- .../management/service/GroupService.java | 8 ++--- .../service/OrganizationService.java | 6 ++-- .../management/service/RoleService.java | 23 +++++++-------- .../management/service/SourceDataService.java | 9 +++--- .../management/service/SourceService.java | 12 ++++---- .../management/service/SourceTypeService.java | 21 +++++++------- .../management/service/SubjectService.java | 2 +- .../service/mapper/ClientDetailsMapper.java | 27 ++++++++++------- .../service/mapper/GroupMapper.java | 3 +- .../management/service/mapper/UserMapper.java | 4 ++- .../ClientDetailsMapperDecorator.java | 9 +++--- .../web/rest/AuthorityResource.java | 22 +++++++------- .../management/web/rest/LogsResource.java | 6 ++-- .../management/web/rest/SubjectResource.java | 3 +- .../web/rest/criteria/SubjectCriteria.java | 17 +++++------ .../auth/authentication/OAuthHelper.java | 3 +- 22 files changed, 152 insertions(+), 128 deletions(-) create mode 100644 src/main/java/org/radarbase/management/config/AuthorizationConfiguration.kt diff --git a/src/main/java/org/radarbase/management/config/AuthorizationConfiguration.kt b/src/main/java/org/radarbase/management/config/AuthorizationConfiguration.kt new file mode 100644 index 000000000..96900e083 --- /dev/null +++ b/src/main/java/org/radarbase/management/config/AuthorizationConfiguration.kt @@ -0,0 +1,26 @@ +package org.radarbase.management.config + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.authorization.EntityRelationService +import org.radarbase.auth.authorization.MPAuthorizationOracle +import org.radarbase.management.repository.ProjectRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +open class AuthorizationConfiguration( + private val projectRepository: ProjectRepository, +) { + @Bean + open fun authorizationOracle(): AuthorizationOracle = MPAuthorizationOracle( + object : EntityRelationService { + override suspend fun findOrganizationOfProject(project: String): String? = withContext(Dispatchers.IO) { + projectRepository.findOneWithEagerRelationshipsByName(project) + .map { it.organization.name } + .orElse(null) + } + } + ) +} diff --git a/src/main/java/org/radarbase/management/config/OAuth2LoginUiWebConfig.java b/src/main/java/org/radarbase/management/config/OAuth2LoginUiWebConfig.java index 5ded200a1..dae7d135d 100644 --- a/src/main/java/org/radarbase/management/config/OAuth2LoginUiWebConfig.java +++ b/src/main/java/org/radarbase/management/config/OAuth2LoginUiWebConfig.java @@ -1,17 +1,5 @@ package org.radarbase.management.config; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.TreeMap; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.oauth2.common.exceptions.OAuth2Exception; import org.springframework.security.oauth2.common.util.OAuth2Utils; @@ -24,6 +12,19 @@ import org.springframework.web.servlet.ModelAndView; import org.springframework.web.util.HtmlUtils; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + /** * Created by dverbeec on 6/07/2017. */ @@ -96,10 +97,10 @@ public ModelAndView handleOAuthClientError(HttpServletRequest req) { errorParams.put("message", HtmlUtils.htmlEscape(oauthError.getMessage())); // transform the additionalInfo map to a comma seperated list of key: value pairs if (oauthError.getAdditionalInformation() != null) { - errorParams.put("additionalInfo", HtmlUtils.htmlEscape(String.join(", ", + errorParams.put("additionalInfo", HtmlUtils.htmlEscape( oauthError.getAdditionalInformation().entrySet().stream() .map(entry -> entry.getKey() + ": " + entry.getValue()) - .collect(Collectors.toList())))); + .collect(Collectors.joining(", ")))); } } // Copy non-empty entries to the model. Empty entries will not be present in the model, diff --git a/src/main/java/org/radarbase/management/repository/filters/SubjectSpecification.java b/src/main/java/org/radarbase/management/repository/filters/SubjectSpecification.java index a9b5ed658..d403bc322 100644 --- a/src/main/java/org/radarbase/management/repository/filters/SubjectSpecification.java +++ b/src/main/java/org/radarbase/management/repository/filters/SubjectSpecification.java @@ -66,7 +66,7 @@ public SubjectSpecification(SubjectCriteria criteria) { if (last != null) { this.sortLastValues = this.sort.stream() .map(o -> getLastValue(o.getSortBy())) - .collect(Collectors.toList()); + .toList(); } else { this.sortLastValues = null; } @@ -198,6 +198,6 @@ private List getSortOrder(Root root, return builder.desc(path); } }) - .collect(Collectors.toList()); + .toList(); } } diff --git a/src/main/java/org/radarbase/management/repository/filters/UserFilter.java b/src/main/java/org/radarbase/management/repository/filters/UserFilter.java index c2df8be75..e97ec723d 100644 --- a/src/main/java/org/radarbase/management/repository/filters/UserFilter.java +++ b/src/main/java/org/radarbase/management/repository/filters/UserFilter.java @@ -21,7 +21,6 @@ import java.util.List; import java.util.Locale; import java.util.function.BiConsumer; -import java.util.stream.Collectors; import java.util.stream.Stream; public class UserFilter implements Specification { @@ -61,7 +60,7 @@ private void filterRoles(PredicateBuilder predicates, Join roleJoin, .filter(r -> r != null && r.getAuthority().contains(authorityUpper)); allowNoRole = false; } - List authoritiesAllowed = authoritiesFiltered.collect(Collectors.toList()); + List authoritiesAllowed = authoritiesFiltered.toList(); if (authoritiesAllowed.isEmpty()) { CriteriaBuilder builder = predicates.getCriteriaBuilder(); // never match @@ -129,7 +128,7 @@ private boolean addAllowedAuthorities(PredicateBuilder predicates, } List authorityNames = authorityStream .map(RoleAuthority::getAuthority) - .collect(Collectors.toList()); + .toList(); if (!authorityNames.isEmpty()) { predicates.in(roleJoin.get("authority").get("name"), authorityNames); diff --git a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java index f4afc6a99..fc3a3a553 100644 --- a/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java +++ b/src/main/java/org/radarbase/management/security/ClaimsTokenEnhancer.java @@ -1,11 +1,12 @@ package org.radarbase.management.security; +import org.radarbase.auth.authorization.AuthorizationOracle; import org.radarbase.auth.authorization.Permission; +import org.radarbase.auth.authorization.RoleAuthority; import org.radarbase.management.domain.Role; import org.radarbase.management.domain.Source; import org.radarbase.management.repository.SubjectRepository; import org.radarbase.management.repository.UserRepository; -import org.radarbase.management.service.AuthService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -22,6 +23,7 @@ import java.security.Principal; import java.time.Instant; +import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,7 +48,7 @@ public class ClaimsTokenEnhancer implements TokenEnhancer, InitializingBean { private AuditEventRepository auditEventRepository; @Autowired - private AuthService authService; + private AuthorizationOracle authorizationOracle; @Value("${spring.application.name}") private String appName; @@ -80,7 +82,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, + ":" + auth; }; }) - .collect(Collectors.toList()); + .toList(); additionalInfo.put(ROLES_CLAIM, roles); // Do not grant scopes that cannot be given to a user. @@ -88,9 +90,12 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, Set newScopes = currentScopes.stream() .filter(scope -> { Permission permission = Permission.ofScope(scope); - return user.getRoles().stream() + var roleAuthorities = user.getRoles().stream() .map(Role::getRole) - .anyMatch(r -> authService.mayBeGranted(r, permission)); + .collect(Collectors.toCollection(() -> + EnumSet.noneOf(RoleAuthority.class))); + return authorizationOracle.mayBeGranted(roleAuthorities, + permission); }) .collect(Collectors.toCollection(TreeSet::new)); @@ -103,7 +108,7 @@ public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, List sourceIds = assignedSources.stream() .map(s -> s.getSourceId().toString()) - .collect(Collectors.toList()); + .toList(); additionalInfo.put(SOURCES_CLAIM, sourceIds); } // add iat and iss optional JWT claims diff --git a/src/main/java/org/radarbase/management/service/AuthService.kt b/src/main/java/org/radarbase/management/service/AuthService.kt index 365eaf9db..e574220a5 100644 --- a/src/main/java/org/radarbase/management/service/AuthService.kt +++ b/src/main/java/org/radarbase/management/service/AuthService.kt @@ -1,32 +1,20 @@ package org.radarbase.management.service -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.radarbase.auth.authorization.* import org.radarbase.auth.token.RadarToken import org.radarbase.management.security.NotAuthorizedException -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import java.util.* import java.util.function.Consumer +import javax.annotation.Nullable @Service -open class AuthService { - @Autowired - private lateinit var projectService: ProjectService - - @Autowired(required = false) - private var token: RadarToken? = null - - private val oracle: AuthorizationOracle = MPAuthorizationOracle( - object : EntityRelationService { - override suspend fun findOrganizationOfProject(project: String): String = withContext(Dispatchers.IO) { - projectService.findOneByName(project).organization.name - } - } - ) - +open class AuthService( + @Nullable + private val token: RadarToken?, + private val oracle: AuthorizationOracle, +) { /** * Check whether given [token] would have the [permission] scope in any of its roles. This doesn't * check whether [token] has access to a specific entity or global access. diff --git a/src/main/java/org/radarbase/management/service/GroupService.java b/src/main/java/org/radarbase/management/service/GroupService.java index 01b08d551..016349e3f 100644 --- a/src/main/java/org/radarbase/management/service/GroupService.java +++ b/src/main/java/org/radarbase/management/service/GroupService.java @@ -25,10 +25,8 @@ import org.springframework.stereotype.Service; import javax.transaction.Transactional; - import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; import static org.radarbase.management.web.rest.errors.EntityName.GROUP; import static org.radarbase.management.web.rest.errors.EntityName.PROJECT; @@ -163,13 +161,15 @@ public void updateGroupSubjects( if (!entitiesToAdd.isEmpty()) { List idsToAdd = entitiesToAdd.stream() - .map(Subject::getId).collect(Collectors.toList()); + .map(Subject::getId) + .toList(); subjectRepository.setGroupIdByIds(group.getId(), idsToAdd); } if (!entitiesToRemove.isEmpty()) { List idsToRemove = entitiesToRemove.stream() - .map(Subject::getId).collect(Collectors.toList()); + .map(Subject::getId) + .toList(); subjectRepository.unsetGroupIdByIds(idsToRemove); } } diff --git a/src/main/java/org/radarbase/management/service/OrganizationService.java b/src/main/java/org/radarbase/management/service/OrganizationService.java index 900eb32fe..df6c66cbc 100644 --- a/src/main/java/org/radarbase/management/service/OrganizationService.java +++ b/src/main/java/org/radarbase/management/service/OrganizationService.java @@ -18,7 +18,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import java.util.stream.Stream; import static org.radarbase.auth.authorization.Permission.ORGANIZATION_READ; @@ -43,6 +42,7 @@ public class OrganizationService { @Autowired private ProjectMapper projectMapper; + @Autowired private AuthService authService; @@ -85,7 +85,7 @@ public List findAll() { organizationsOfUser = Stream.concat(organizationsOfRole, organizationsOfProject) .distinct() - .collect(Collectors.toList()); + .toList(); } return organizationMapper.organizationsToOrganizationDTOs(organizationsOfUser); @@ -129,6 +129,6 @@ public List findAllProjectsByOrganizationName(String organizationNam return projectStream .map(projectMapper::projectToProjectDTO) - .collect(Collectors.toList()); + .toList(); } } diff --git a/src/main/java/org/radarbase/management/service/RoleService.java b/src/main/java/org/radarbase/management/service/RoleService.java index da580a6ba..b12a329eb 100644 --- a/src/main/java/org/radarbase/management/service/RoleService.java +++ b/src/main/java/org/radarbase/management/service/RoleService.java @@ -1,12 +1,5 @@ package org.radarbase.management.service; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.stream.Collectors; import org.radarbase.auth.authorization.RoleAuthority; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.Role; @@ -26,6 +19,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + import static org.radarbase.management.web.rest.errors.EntityName.USER; /** @@ -87,13 +86,13 @@ public List findAll() { User currentUser = optUser.get(); List currentUserAuthorities = currentUser.getAuthorities().stream() .map(Authority::getName) - .collect(Collectors.toList()); + .toList(); if (currentUserAuthorities.contains(RoleAuthority.SYS_ADMIN.getAuthority())) { log.debug("Request to get all Roles"); return roleRepository.findAll().stream() .map(roleMapper::roleToRoleDTO) - .collect(Collectors.toList()); + .toList(); } else if (currentUserAuthorities.contains(RoleAuthority.PROJECT_ADMIN.getAuthority())) { log.debug("Request to get project admin's project Projects"); return currentUser.getRoles().stream() @@ -103,7 +102,7 @@ public List findAll() { .distinct() .flatMap(name -> roleRepository.findAllRolesByProjectName(name).stream()) .map(roleMapper::roleToRoleDTO) - .collect(Collectors.toList()); + .toList(); } else { return Collections.emptyList(); } @@ -121,7 +120,7 @@ public List findSuperAdminRoles() { return roleRepository .findRolesByAuthorityName(RoleAuthority.SYS_ADMIN.getAuthority()).stream() .map(roleMapper::roleToRoleDTO) - .collect(Collectors.toCollection(LinkedList::new)); + .toList(); } /** @@ -242,7 +241,7 @@ public List getRolesByProject(String projectName) { return roleRepository.findAllRolesByProjectName(projectName).stream() .map(roleMapper::roleToRoleDTO) - .collect(Collectors.toCollection(LinkedList::new)); + .toList(); } private Authority getAuthority(RoleAuthority role) { diff --git a/src/main/java/org/radarbase/management/service/SourceDataService.java b/src/main/java/org/radarbase/management/service/SourceDataService.java index 7354d19f2..d838e170a 100644 --- a/src/main/java/org/radarbase/management/service/SourceDataService.java +++ b/src/main/java/org/radarbase/management/service/SourceDataService.java @@ -1,9 +1,5 @@ package org.radarbase.management.service; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; import org.radarbase.management.domain.SourceData; import org.radarbase.management.repository.SourceDataRepository; import org.radarbase.management.service.dto.SourceDataDTO; @@ -17,6 +13,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; + import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_DATA; /** @@ -66,7 +65,7 @@ public List findAll() { return sourceDataRepository.findAll().stream() .map(sourceDataMapper::sourceDataToSourceDataDTO) - .collect(Collectors.toCollection(LinkedList::new)); + .toList(); } /** diff --git a/src/main/java/org/radarbase/management/service/SourceService.java b/src/main/java/org/radarbase/management/service/SourceService.java index 988b5f3d8..a63fe32ca 100644 --- a/src/main/java/org/radarbase/management/service/SourceService.java +++ b/src/main/java/org/radarbase/management/service/SourceService.java @@ -22,11 +22,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import static org.hibernate.id.IdentifierGenerator.ENTITY_NAME; import static org.radarbase.auth.authorization.Permission.SOURCE_UPDATE; @@ -79,7 +77,7 @@ public List findAll() { .findAll() .stream() .map(sourceMapper::sourceToSourceDTO) - .collect(Collectors.toCollection(LinkedList::new)); + .toList(); } /** @@ -130,8 +128,10 @@ public Optional findOneById(Long id) { public void delete(Long id) { log.info("Request to delete Source : {}", id); Revisions sourceHistory = sourceRepository.findRevisions(id); - List sources = sourceHistory.getContent().stream().map(Revision::getEntity) - .filter(Source::isAssigned).collect(Collectors.toList()); + List sources = sourceHistory.getContent().stream() + .map(Revision::getEntity) + .filter(Source::isAssigned) + .toList(); if (sources.isEmpty()) { sourceRepository.deleteById(id); } else { @@ -181,7 +181,7 @@ public List findAllMinimalSourceDetailsByProjectAndAssi .findAllSourcesByProjectIdAndAssigned(projectId, assigned) .stream() .map(sourceMapper::sourceToMinimalSourceDetailsDTO) - .collect(Collectors.toList()); + .toList(); } /** diff --git a/src/main/java/org/radarbase/management/service/SourceTypeService.java b/src/main/java/org/radarbase/management/service/SourceTypeService.java index 2b6e6f562..655c27fde 100644 --- a/src/main/java/org/radarbase/management/service/SourceTypeService.java +++ b/src/main/java/org/radarbase/management/service/SourceTypeService.java @@ -1,14 +1,5 @@ package org.radarbase.management.service; -import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_TYPE; -import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SOURCE_TYPE_NOT_FOUND; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.stream.Collectors; -import javax.validation.constraints.NotNull; - import org.radarbase.management.domain.SourceData; import org.radarbase.management.domain.SourceType; import org.radarbase.management.repository.SourceDataRepository; @@ -30,6 +21,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import javax.validation.constraints.NotNull; +import java.util.Collections; +import java.util.List; + +import static org.radarbase.management.web.rest.errors.EntityName.SOURCE_TYPE; +import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_SOURCE_TYPE_NOT_FOUND; + /** * Service Implementation for managing SourceType. */ @@ -84,8 +82,9 @@ public SourceTypeDTO save(SourceTypeDTO sourceTypeDto) { public List findAll() { log.debug("Request to get all SourceTypes"); List result = sourceTypeRepository.findAllWithEagerRelationships(); - return result.stream().map(sourceTypeMapper::sourceTypeToSourceTypeDTO) - .collect(Collectors.toCollection(LinkedList::new)); + return result.stream() + .map(sourceTypeMapper::sourceTypeToSourceTypeDTO) + .toList(); } diff --git a/src/main/java/org/radarbase/management/service/SubjectService.java b/src/main/java/org/radarbase/management/service/SubjectService.java index c4f1de3bd..6339760c3 100644 --- a/src/main/java/org/radarbase/management/service/SubjectService.java +++ b/src/main/java/org/radarbase/management/service/SubjectService.java @@ -428,7 +428,7 @@ public List findSubjectSourcesFromRevisions(Subject sub .filter(distinctByKey(Source::getSourceId)) .collect(Collectors.toSet()); return sources.stream().map(p -> sourceMapper.sourceToMinimalSourceDetailsDTO(p)) - .collect(Collectors.toList()); + .toList(); } /** diff --git a/src/main/java/org/radarbase/management/service/mapper/ClientDetailsMapper.java b/src/main/java/org/radarbase/management/service/mapper/ClientDetailsMapper.java index eb81e2ddb..70f8d0c9f 100644 --- a/src/main/java/org/radarbase/management/service/mapper/ClientDetailsMapper.java +++ b/src/main/java/org/radarbase/management/service/mapper/ClientDetailsMapper.java @@ -1,12 +1,5 @@ package org.radarbase.management.service.mapper; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; import org.mapstruct.DecoratedWith; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -17,6 +10,14 @@ import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.client.BaseClientDetails; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + /** * Created by dverbeec on 7/09/2017. */ @@ -43,7 +44,9 @@ default Collection map(Set authorities) { if (Objects.isNull(authorities)) { return Collections.emptySet(); } - return authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()); + return authorities.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); } /** @@ -55,7 +58,9 @@ default Set map(Collection authorities) { if (Objects.isNull(authorities)) { return Collections.emptySet(); } - return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); + return authorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); } /** @@ -69,7 +74,7 @@ default Map map(Map additionalInformation) { if (Objects.isNull(additionalInformation)) { return Collections.emptyMap(); } - return additionalInformation.entrySet().stream().collect( - Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toString())); + return additionalInformation.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toString())); } } diff --git a/src/main/java/org/radarbase/management/service/mapper/GroupMapper.java b/src/main/java/org/radarbase/management/service/mapper/GroupMapper.java index a4c70d071..50962275f 100644 --- a/src/main/java/org/radarbase/management/service/mapper/GroupMapper.java +++ b/src/main/java/org/radarbase/management/service/mapper/GroupMapper.java @@ -12,6 +12,7 @@ import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; import org.mapstruct.Named; import org.radarbase.management.domain.Group; import org.radarbase.management.service.dto.GroupDTO; @@ -19,7 +20,7 @@ import java.util.Collection; import java.util.List; -@Mapper(componentModel = "spring") +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface GroupMapper { @Named("groupToGroupDTO") @Mapping(target = "id", ignore = true) diff --git a/src/main/java/org/radarbase/management/service/mapper/UserMapper.java b/src/main/java/org/radarbase/management/service/mapper/UserMapper.java index 69991ec54..9ba2236cb 100644 --- a/src/main/java/org/radarbase/management/service/mapper/UserMapper.java +++ b/src/main/java/org/radarbase/management/service/mapper/UserMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.DecoratedWith; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.User; import org.radarbase.management.service.dto.UserDTO; @@ -14,7 +15,8 @@ /** * Mapper for the entity User and its DTO UserDTO. */ -@Mapper(componentModel = "spring", uses = {ProjectMapper.class, RoleMapper.class}) +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, + uses = {ProjectMapper.class, RoleMapper.class}) @DecoratedWith(UserMapperDecorator.class) public interface UserMapper { diff --git a/src/main/java/org/radarbase/management/service/mapper/decorator/ClientDetailsMapperDecorator.java b/src/main/java/org/radarbase/management/service/mapper/decorator/ClientDetailsMapperDecorator.java index d76847a4b..c98f70bfe 100644 --- a/src/main/java/org/radarbase/management/service/mapper/decorator/ClientDetailsMapperDecorator.java +++ b/src/main/java/org/radarbase/management/service/mapper/decorator/ClientDetailsMapperDecorator.java @@ -1,15 +1,16 @@ package org.radarbase.management.service.mapper.decorator; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; import org.radarbase.management.service.dto.ClientDetailsDTO; import org.radarbase.management.service.mapper.ClientDetailsMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.oauth2.provider.ClientDetails; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + /** * Decorator for ClientDetailsMapper. The ClientDetails interface does not expose a method to get * all auto-approve scopes, instead it only has a method to check if a given scope is auto-approve. @@ -38,6 +39,6 @@ public List clientDetailsToClientDetailsDTO(List ALL_AUTHORITIES = Stream.of( + RoleAuthority.SYS_ADMIN, + RoleAuthority.ORGANIZATION_ADMIN, + RoleAuthority.PROJECT_ADMIN, + RoleAuthority.PROJECT_OWNER, + RoleAuthority.PROJECT_AFFILIATE, + RoleAuthority.PROJECT_ANALYST) + .map(AuthorityDTO::new) + .toList(); + @Autowired private AuthService authService; @@ -38,14 +48,6 @@ public class AuthorityResource { public List getAllAuthorities() throws NotAuthorizedException { log.debug("REST request to get all Authorities"); authService.checkScope(AUTHORITY_READ); - return Stream.of( - RoleAuthority.SYS_ADMIN, - RoleAuthority.ORGANIZATION_ADMIN, - RoleAuthority.PROJECT_ADMIN, - RoleAuthority.PROJECT_OWNER, - RoleAuthority.PROJECT_AFFILIATE, - RoleAuthority.PROJECT_ANALYST) - .map(AuthorityDTO::new) - .collect(Collectors.toList()); + return ALL_AUTHORITIES; } } diff --git a/src/main/java/org/radarbase/management/web/rest/LogsResource.java b/src/main/java/org/radarbase/management/web/rest/LogsResource.java index 8c94ea56f..16101a535 100644 --- a/src/main/java/org/radarbase/management/web/rest/LogsResource.java +++ b/src/main/java/org/radarbase/management/web/rest/LogsResource.java @@ -3,8 +3,6 @@ import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import io.micrometer.core.annotation.Timed; -import java.util.List; -import java.util.stream.Collectors; import org.radarbase.auth.authorization.RoleAuthority; import org.radarbase.management.web.rest.vm.LoggerVM; import org.slf4j.LoggerFactory; @@ -17,6 +15,8 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * Controller for view and managing Log Level at runtime. */ @@ -36,7 +36,7 @@ public List getList() { return context.getLoggerList() .stream() .map(LoggerVM::new) - .collect(Collectors.toList()); + .toList(); } /** diff --git a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java index d405a8c14..14669330a 100644 --- a/src/main/java/org/radarbase/management/web/rest/SubjectResource.java +++ b/src/main/java/org/radarbase/management/web/rest/SubjectResource.java @@ -58,7 +58,6 @@ import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.Stream; import static org.radarbase.auth.authorization.Permission.SUBJECT_CREATE; @@ -239,7 +238,7 @@ public ResponseEntity> getAllSubjects( List authoritiesToInclude = subjectCriteria.getAuthority().stream() .filter(Objects::nonNull) .map(Enum::name) - .collect(Collectors.toList()); + .toList(); if (projectName != null && externalId != null) { Optional> subject = subjectRepository diff --git a/src/main/java/org/radarbase/management/web/rest/criteria/SubjectCriteria.java b/src/main/java/org/radarbase/management/web/rest/criteria/SubjectCriteria.java index 9fbd838b6..a13d3ddb2 100644 --- a/src/main/java/org/radarbase/management/web/rest/criteria/SubjectCriteria.java +++ b/src/main/java/org/radarbase/management/web/rest/criteria/SubjectCriteria.java @@ -19,7 +19,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import static org.radarbase.management.web.rest.errors.EntityName.SUBJECT; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_VALIDATION; @@ -156,26 +155,26 @@ public List getParsedSort() { List flatSort = sort != null ? sort.stream() .flatMap(s -> Arrays.stream(s.split(","))) - .collect(Collectors.toList()) + .toList() : List.of(); List parsedSort = new ArrayList<>(flatSort.size()); boolean hasDirection = true; + SubjectSortOrder previous = null; for (String part : flatSort) { if (!hasDirection) { - Optional direction = Sort.Direction.fromOptionalString( - part); + Optional direction = Sort.Direction.fromOptionalString(part); if (direction.isPresent()) { - SubjectSortOrder previous = parsedSort.get(parsedSort.size() - 1); previous.setDirection(direction.get()); hasDirection = true; continue; } + } else { + hasDirection = false; } - SubjectSortOrder order = new SubjectSortOrder(getSubjectSortBy(part)); - parsedSort.add(order); - hasDirection = false; + previous = new SubjectSortOrder(getSubjectSortBy(part)); + parsedSort.add(previous); } optimizeSortList(parsedSort); @@ -185,7 +184,7 @@ public List getParsedSort() { } /** - * Remove duplication and redundancy from sort list. + * Remove duplication and redundancy from sort list and make the result order consistent. * @param sort modifiable ordered sort collection. */ private static void optimizeSortList(Collection sort) { diff --git a/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java b/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java index 60367b048..740c59b65 100644 --- a/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java +++ b/src/test/java/org/radarbase/auth/authentication/OAuthHelper.java @@ -24,7 +24,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import java.util.stream.Stream; import static org.mockito.ArgumentMatchers.anyString; @@ -124,7 +123,7 @@ public static void setUp() throws Exception { var verifierList = Stream.of(ecdsa, rsa) .map(alg -> toTokenVerifier(alg, RES_MANAGEMENT_PORTAL)) - .collect(Collectors.toList()); + .toList(); verifiers = List.of(new StaticTokenVerifierLoader(verifierList)); } From 0e4d42e9c09a0851945fa28d0c9de9f7979d4a1e Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 1 Jun 2023 15:25:59 +0200 Subject: [PATCH 034/120] Refactor token handling --- .../radarbase/auth/jwt/JwtTokenVerifier.kt | 6 +- .../management/ManagementPortalApp.java | 13 +- .../config/OAuth2ServerConfiguration.java | 26 +- .../config/RadarTokenConfiguration.java | 30 +++ .../config/SecurityConfiguration.java | 31 --- .../management/security/Constants.java | 2 +- .../security/DomainUserDetailsService.java | 67 +---- .../security/JwtAuthenticationFilter.java | 238 ------------------ .../security/JwtAuthenticationFilter.kt | 223 ++++++++++++++++ .../security/jwt/RadarTokenLoader.kt | 20 ++ .../management/service/MetaTokenService.java | 2 +- .../service/OAuthClientService.java | 10 +- .../management/web/rest/AccountResource.java | 4 +- .../web/rest/AccountResourceIntTest.java | 4 +- 14 files changed, 312 insertions(+), 364 deletions(-) create mode 100644 src/main/java/org/radarbase/management/config/RadarTokenConfiguration.java delete mode 100644 src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.kt create mode 100644 src/main/java/org/radarbase/management/security/jwt/RadarTokenLoader.kt diff --git a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt index 035f2f23c..e6b2531f6 100644 --- a/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt +++ b/radar-auth/src/main/java/org/radarbase/auth/jwt/JwtTokenVerifier.kt @@ -37,13 +37,13 @@ class JwtTokenVerifier( companion object { private val logger = LoggerFactory.getLogger(JwtTokenVerifier::class.java) - private const val AUTHORITIES_CLAIM = "authorities" + const val AUTHORITIES_CLAIM = "authorities" const val ROLES_CLAIM = "roles" const val SCOPE_CLAIM = "scope" const val SOURCES_CLAIM = "sources" const val GRANT_TYPE_CLAIM = "grant_type" - private const val CLIENT_ID_CLAIM = "client_id" - private const val USER_NAME_CLAIM = "user_name" + const val CLIENT_ID_CLAIM = "client_id" + const val USER_NAME_CLAIM = "user_name" fun DecodedJWT.toRadarToken(): RadarToken { val claims = claims diff --git a/src/main/java/org/radarbase/management/ManagementPortalApp.java b/src/main/java/org/radarbase/management/ManagementPortalApp.java index c62844276..7bfc158d2 100644 --- a/src/main/java/org/radarbase/management/ManagementPortalApp.java +++ b/src/main/java/org/radarbase/management/ManagementPortalApp.java @@ -78,11 +78,14 @@ public static void main(String[] args) throws UnknownHostException { if (env.getProperty("server.ssl.key-store") != null) { protocol = "https"; } - log.info("\n-----------------------------------------------------\n\t" - + "Application '{}' is running! Access URLs:\n\t" - + "Local: \t\t{}://localhost:{}\n\t" - + "External: \t{}://{}:{}\n\t" - + "Profile(s): \t{}\n-----------------------------------------------------", + log.info(""" + + ----------------------------------------------------- + \tApplication '{}' is running! Access URLs: + \tLocal: \t\t{}://localhost:{} + \tExternal: \t{}://{}:{} + \tProfile(s): \t{} + -----------------------------------------------------""", env.getProperty("spring.application.name"), protocol, env.getProperty("server.port"), diff --git a/src/main/java/org/radarbase/management/config/OAuth2ServerConfiguration.java b/src/main/java/org/radarbase/management/config/OAuth2ServerConfiguration.java index 809c395b7..a433588f8 100644 --- a/src/main/java/org/radarbase/management/config/OAuth2ServerConfiguration.java +++ b/src/main/java/org/radarbase/management/config/OAuth2ServerConfiguration.java @@ -66,25 +66,22 @@ public class OAuth2ServerConfiguration { @Configuration @Order(-20) protected static class LoginConfig extends WebSecurityConfigurerAdapter { - @Autowired private AuthenticationManager authenticationManager; @Autowired - private UserRepository userRepository; - - @Autowired - private ManagementPortalOauthKeyStoreHandler keyStoreHandler; + private JwtAuthenticationFilter jwtAuthenticationFilter; @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin().loginPage("/login").permitAll() .and() - .addFilterBefore(jwtAuthenticationFilter(), + .addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .requestMatchers() .antMatchers("/login", "/oauth/authorize", "/oauth/confirm_access") + .and() .authorizeRequests().anyRequest().authenticated(); } @@ -94,6 +91,20 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.parentAuthenticationManager(authenticationManager); } + } + + @Configuration + public static class JwtAuthenticationFilterConfiguration { + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ManagementPortalOauthKeyStoreHandler keyStoreHandler; + + @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter( keyStoreHandler.getTokenValidator(), @@ -195,9 +206,8 @@ protected static class CustomEventPublisher extends DefaultAuthenticationEventPu @Override public void publishAuthenticationSuccess(Authentication authentication) { // OAuth2AuthenticationProcessingFilter publishes an authentication success audit - // event for EVERY successful OAuth request to our API resoruces, this is way too + // event for EVERY successful OAuth request to our API resources, this is way too // much so we override the event publisher to not publish these events. - } } diff --git a/src/main/java/org/radarbase/management/config/RadarTokenConfiguration.java b/src/main/java/org/radarbase/management/config/RadarTokenConfiguration.java new file mode 100644 index 000000000..6f5633bf4 --- /dev/null +++ b/src/main/java/org/radarbase/management/config/RadarTokenConfiguration.java @@ -0,0 +1,30 @@ +package org.radarbase.management.config; + +import org.radarbase.auth.token.RadarToken; +import org.radarbase.management.security.jwt.RadarTokenLoader; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; + +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; + +import static org.springframework.context.annotation.ScopedProxyMode.TARGET_CLASS; + +@Configuration +public class RadarTokenConfiguration { + private final RadarTokenLoader radarTokenLoader; + + @Autowired + public RadarTokenConfiguration(RadarTokenLoader radarTokenLoader) { + this.radarTokenLoader = radarTokenLoader; + } + + @Scope(value = "request", proxyMode = TARGET_CLASS) + @Bean + @Nullable + public RadarToken radarToken(HttpServletRequest request) { + return radarTokenLoader.loadToken(request); + } +} diff --git a/src/main/java/org/radarbase/management/config/SecurityConfiguration.java b/src/main/java/org/radarbase/management/config/SecurityConfiguration.java index 59ed7f262..1f85f5b0c 100644 --- a/src/main/java/org/radarbase/management/config/SecurityConfiguration.java +++ b/src/main/java/org/radarbase/management/config/SecurityConfiguration.java @@ -1,7 +1,6 @@ package org.radarbase.management.config; -import org.radarbase.auth.token.RadarToken; import org.radarbase.management.security.Http401UnauthorizedEntryPoint; import org.radarbase.management.security.RadarAuthenticationProvider; import org.springframework.beans.factory.BeanInitializationException; @@ -9,9 +8,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Scope; import org.springframework.http.HttpMethod; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; @@ -28,11 +25,6 @@ import tech.jhipster.security.AjaxLogoutSuccessHandler; import javax.annotation.PostConstruct; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; -import static org.springframework.context.annotation.ScopedProxyMode.TARGET_CLASS; @Configuration @EnableWebSecurity @@ -125,27 +117,4 @@ public AuthenticationManager authenticationManagerBean() throws Exception { public SecurityEvaluationContextExtension securityEvaluationContextExtension() { return new SecurityEvaluationContextExtension(); } - - @Scope(value = "request", proxyMode = TARGET_CLASS) - @Bean - public RadarToken radarToken(HttpServletRequest request) { - Object token = request.getAttribute(TOKEN_ATTRIBUTE); - if (token == null) { - HttpSession session = request.getSession(false); - if (session != null) { - token = session.getAttribute(TOKEN_ATTRIBUTE); - } - } - if (token == null) { - // should not happen, the JwtAuthenticationFilter would throw an exception first if it - // can not decode the authorization header into a valid JWT - throw new AccessDeniedException("No token was found in the request context."); - } - if (!(token instanceof RadarToken)) { - // should not happen, the JwtAuthenticationFilter will only set a DecodedJWT object - throw new AccessDeniedException("Expected token to be of type org.radarbase" - + ".auth.token.RadarToken but was " + token.getClass().getName()); - } - return (RadarToken) token; - } } diff --git a/src/main/java/org/radarbase/management/security/Constants.java b/src/main/java/org/radarbase/management/security/Constants.java index cc563f8c8..a713c34fc 100644 --- a/src/main/java/org/radarbase/management/security/Constants.java +++ b/src/main/java/org/radarbase/management/security/Constants.java @@ -9,7 +9,7 @@ public final class Constants { public static final String TOKEN_NAME_REGEX = "^[A-Za-z0-9.-]*$"; public static final String SYSTEM_ACCOUNT = "system"; - public static final String ANONYMOUS_USER = "anonymoususer"; + public static final String ANONYMOUS_USER = "anonymousUser"; private Constants() { } diff --git a/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java b/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java index 3c626d0c9..3accf81bb 100644 --- a/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java +++ b/src/main/java/org/radarbase/management/security/DomainUserDetailsService.java @@ -1,9 +1,5 @@ package org.radarbase.management.security; -import org.radarbase.auth.authorization.AuthorityReference; -import org.radarbase.auth.authorization.Permission; -import org.radarbase.auth.authorization.RoleAuthority; -import org.radarbase.auth.token.DataRadarToken; import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; import org.slf4j.Logger; @@ -16,23 +12,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import javax.annotation.Nullable; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; -import java.time.Instant; -import java.util.Arrays; -import java.util.Collections; -import java.util.EnumSet; import java.util.List; import java.util.Locale; -import java.util.TreeSet; import java.util.stream.Collectors; -import static org.radarbase.auth.authorization.MPAuthorizationOracle.allowedRoles; -import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; -import static org.radarbase.management.security.JwtAuthenticationFilter.userAuthorities; -import static org.radarbase.management.security.jwt.ManagementPortalJwtAccessTokenConverter.RES_MANAGEMENT_PORTAL; - /** * Authenticate a user from the database. */ @@ -43,13 +26,9 @@ public class DomainUserDetailsService implements UserDetailsService { private final UserRepository userRepository; - private final HttpServletRequest httpRequest; - public DomainUserDetailsService( - UserRepository userRepository, - @Nullable HttpServletRequest httpRequest) { + UserRepository userRepository) { this.userRepository = userRepository; - this.httpRequest = httpRequest; } @Override @@ -64,7 +43,6 @@ public UserDetails loadUserByUsername(final String login) { throw new UserNotActivatedException("User " + lowercaseLogin + " was not activated"); } - addTokenToSession(user); List grantedAuthorities = user.getAuthorities().stream() .map(authority -> new SimpleGrantedAuthority(authority.getName())) @@ -75,47 +53,4 @@ public UserDetails loadUserByUsername(final String login) { user.getPassword(), grantedAuthorities); } - - private void addTokenToSession(User user) { - if (httpRequest == null) { - return; - } - var roles = userAuthorities(user); - - var roleAuthorities = roles.stream() - .map(AuthorityReference::getRole) - .collect(Collectors.toCollection(() -> EnumSet.noneOf(RoleAuthority.class))); - - var scopes = Arrays.stream(Permission.values()) - .filter(permission -> !Collections.disjoint( - allowedRoles(permission), - roleAuthorities)) - .map(Permission::scope) - .collect(Collectors.toCollection(TreeSet::new)); - - String subject = roleAuthorities.contains(RoleAuthority.PARTICIPANT) - ? user.getLogin() - : null; - - var token = new DataRadarToken( - roles, - scopes, - List.of(), - "password", - subject, - user.getLogin(), - Instant.now(), - Instant.MAX, - List.of(RES_MANAGEMENT_PORTAL), - null, - null, - "session", - null); - - httpRequest.setAttribute(TOKEN_ATTRIBUTE, token); - HttpSession session = httpRequest.getSession(false); - if (session != null) { - session.setAttribute(TOKEN_ATTRIBUTE, token); - } - } } diff --git a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java deleted file mode 100644 index c3d9051fa..000000000 --- a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.java +++ /dev/null @@ -1,238 +0,0 @@ -package org.radarbase.management.security; - -import org.radarbase.auth.authentication.TokenValidator; -import org.radarbase.auth.authorization.AuthorityReference; -import org.radarbase.auth.exception.TokenValidationException; -import org.radarbase.auth.token.RadarToken; -import org.radarbase.management.domain.User; -import org.radarbase.management.repository.UserRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.web.cors.CorsUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import javax.annotation.Nonnull; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Created by dverbeec on 29/09/2017. - */ -public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); - public static final String AUTHORIZATION_BEARER_HEADER = "Bearer"; - - private final TokenValidator validator; - private final AuthenticationManager authenticationManager; - public static final String TOKEN_ATTRIBUTE = "jwt"; - private final List ignoreUrls; - private final UserRepository userRepository; - private final boolean isOptional; - - /** - * Authority references for given user. The user should have its roles mapped - * from the database. - * @param user user to get authority references from. - * @return set of authority references. - */ - public static Set userAuthorities(User user) { - return user.getRoles().stream() - .map(role -> { - var auth = role.getRole(); - return switch (role.getRole().getScope()) { - case GLOBAL -> new AuthorityReference(auth); - case ORGANIZATION -> new AuthorityReference(auth, - role.getOrganization().getName()); - case PROJECT -> new AuthorityReference(auth, - role.getProject().getProjectName()); - }; - }) - .collect(Collectors.toSet()); - } - - /** - * Authentication filter using given validator. Authentication is mandatory. - * @param validator validates the JWT token. - * @param authenticationManager authentication manager to pass valid authentication to. - * @param userRepository user repository to retrieve user details from. - */ - public JwtAuthenticationFilter(TokenValidator validator, - AuthenticationManager authenticationManager, - UserRepository userRepository) { - this(validator, authenticationManager, userRepository, false); - } - - /** - * Authentication filter using given validator. - * @param validator validates the JWT token. - * @param authenticationManager authentication manager to pass valid authentication to. - * @param userRepository user repository to retrieve user details from. - * @param isOptional do not fail if no authentication is provided - */ - public JwtAuthenticationFilter(TokenValidator validator, - AuthenticationManager authenticationManager, - UserRepository userRepository, - boolean isOptional) { - this.validator = validator; - this.authenticationManager = authenticationManager; - this.userRepository = userRepository; - this.ignoreUrls = new ArrayList<>(); - this.isOptional = isOptional; - } - - /** - * Do not use JWT authentication for given paths and HTTP method. - * @param method HTTP method - * @param antPatterns Ant wildcard pattern - * @return the current filter - */ - public JwtAuthenticationFilter skipUrlPattern(HttpMethod method, String... antPatterns) { - for (String pattern : antPatterns) { - ignoreUrls.add(new AntPathRequestMatcher(pattern, method.name())); - } - return this; - } - - @Override - protected void doFilterInternal(@Nonnull HttpServletRequest httpRequest, - @Nonnull HttpServletResponse httpResponse, @Nonnull FilterChain chain) - throws IOException, ServletException { - if (CorsUtils.isPreFlightRequest(httpRequest)) { - logger.debug("Skipping JWT check for preflight request"); - chain.doFilter(httpRequest, httpResponse); - return; - } - - HttpSession session = httpRequest.getSession(false); - - String stringToken = tokenFromHeader(httpRequest); - RadarToken token = null; - String exMessage = "No token provided"; - if (stringToken != null) { - try { - token = validator.validateBlocking(stringToken); - logger.debug("Using token from header"); - } catch (TokenValidationException ex) { - exMessage = ex.getMessage(); - logger.info("Failed to validate token from session: {}", exMessage); - } - } - - if (token == null) { - token = tokenFromSession(session); - if (token != null) { - logger.debug("Using token from session"); - } - } - - if (validateToken(token, httpRequest, httpResponse, session, exMessage)) { - chain.doFilter(httpRequest, httpResponse); - } - } - - private RadarToken tokenFromSession(HttpSession session) { - if (session != null) { - return (RadarToken) session.getAttribute(TOKEN_ATTRIBUTE); - } else { - return null; - } - } - - @Override - protected boolean shouldNotFilter(@Nonnull HttpServletRequest httpRequest) { - Optional shouldNotFilterUrl = ignoreUrls.stream() - .filter(pattern -> pattern.matches(httpRequest)) - .findAny(); - - if (shouldNotFilterUrl.isPresent()) { - logger.debug("Skipping JWT check for {} request", shouldNotFilterUrl.get()); - return true; - } - - return false; - } - - private String tokenFromHeader(HttpServletRequest httpRequest) { - String authorizationHeader = httpRequest.getHeader(HttpHeaders.AUTHORIZATION); - if (authorizationHeader != null && authorizationHeader - .startsWith(AUTHORIZATION_BEARER_HEADER)) { - return authorizationHeader.substring(AUTHORIZATION_BEARER_HEADER.length()).trim(); - } else { - return null; - } - } - - private boolean validateToken(RadarToken token, HttpServletRequest httpRequest, - HttpServletResponse httpResponse, HttpSession session, String exMessage) - throws IOException { - if (token == null) { - if (isOptional) { - logger.debug("Skipping optional token"); - return true; - } else { - logger.error("Unauthorized - no valid token provided"); - httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, - AUTHORIZATION_BEARER_HEADER); - httpResponse.getOutputStream().print( - "{\"error\": \"" + "Unauthorized" + ",\n" - + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED - + "\",\n" - + "\"message\": \"" + exMessage + "\",\n" - + "\"path\": \"" + httpRequest.getRequestURI() + "\n" - + "\"}"); - return false; - } - } - - RadarToken updatedToken = checkUser(token, httpRequest, httpResponse, session); - if (updatedToken == null) { - return false; - } - - httpRequest.setAttribute(TOKEN_ATTRIBUTE, updatedToken); - RadarAuthentication authentication = new RadarAuthentication(updatedToken); - authenticationManager.authenticate(authentication); - SecurityContextHolder.getContext().setAuthentication(authentication); - return true; - } - - private RadarToken checkUser(RadarToken token, HttpServletRequest httpRequest, - HttpServletResponse httpResponse, HttpSession session) throws IOException { - String userName = token.getUsername(); - if (userName == null) { - return token; - } - var user = userRepository.findOneByLogin(userName); - if (user.isPresent()) { - return token.copyWithRoles(userAuthorities(user.get())); - } else { - if (session != null) { - session.removeAttribute(TOKEN_ATTRIBUTE); - } - httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER); - httpResponse.getOutputStream().print( - "{\"error\": \"" + "Unauthorized" + ",\n" - + "\"status\": \"" + HttpServletResponse.SC_UNAUTHORIZED + ",\n" - + "\"message\": \"User not found\",\n" - + "\"path\": \"" + httpRequest.getRequestURI() + "\n" - + "\"}"); - return null; - } - } -} diff --git a/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.kt b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.kt new file mode 100644 index 000000000..fd72bbb6a --- /dev/null +++ b/src/main/java/org/radarbase/management/security/JwtAuthenticationFilter.kt @@ -0,0 +1,223 @@ +package org.radarbase.management.security + +import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.token.RadarToken +import org.radarbase.management.config.OAuth2ServerConfiguration +import org.radarbase.management.domain.Role +import org.radarbase.management.domain.User +import org.radarbase.management.repository.UserRepository +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.provider.OAuth2Authentication +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.web.cors.CorsUtils +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException +import java.time.Instant +import javax.annotation.Nonnull +import javax.servlet.FilterChain +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse +import javax.servlet.http.HttpSession + +/** + * Authentication filter using given validator. + * @param validator validates the JWT token. + * @param authenticationManager authentication manager to pass valid authentication to. + * @param userRepository user repository to retrieve user details from. + * @param isOptional do not fail if no authentication is provided + */ +class JwtAuthenticationFilter @JvmOverloads constructor( + private val validator: TokenValidator, + private val authenticationManager: AuthenticationManager, + private val userRepository: UserRepository, + private val isOptional: Boolean = false +) : OncePerRequestFilter() { + private val ignoreUrls: MutableList = mutableListOf() + + /** + * Do not use JWT authentication for given paths and HTTP method. + * @param method HTTP method + * @param antPatterns Ant wildcard pattern + * @return the current filter + */ + fun skipUrlPattern(method: HttpMethod, vararg antPatterns: String?): JwtAuthenticationFilter { + for (pattern in antPatterns) { + ignoreUrls.add(AntPathRequestMatcher(pattern, method.name)) + } + return this + } + + @Throws(IOException::class, ServletException::class) + override fun doFilterInternal( + httpRequest: HttpServletRequest, + httpResponse: HttpServletResponse, + chain: FilterChain, + ) { + if (CorsUtils.isPreFlightRequest(httpRequest)) { + Companion.logger.debug("Skipping JWT check for preflight request") + chain.doFilter(httpRequest, httpResponse) + return + } + + val existingAuthentication = SecurityContextHolder.getContext().authentication + + if (existingAuthentication.isAnonymous || existingAuthentication is OAuth2Authentication) { + val session = httpRequest.getSession(false) + val stringToken = tokenFromHeader(httpRequest) + var token: RadarToken? = null + var exMessage = "No token provided" + if (stringToken != null) { + try { + token = validator.validateBlocking(stringToken) + Companion.logger.debug("Using token from header") + } catch (ex: TokenValidationException) { + ex.message?.let { exMessage = it } + Companion.logger.info("Failed to validate token from header: {}", exMessage) + } + } + if (token == null) { + token = session?.radarToken + ?.takeIf { Instant.now() < it.expiresAt } + if (token != null) { + Companion.logger.debug("Using token from session") + } + } + if (!validateToken(token, httpRequest, httpResponse, session, exMessage)) { + return + } + } + chain.doFilter(httpRequest, httpResponse) + } + + override fun shouldNotFilter(@Nonnull httpRequest: HttpServletRequest): Boolean { + val shouldNotFilterUrl = ignoreUrls.find { it.matches(httpRequest) } + return if (shouldNotFilterUrl != null) { + Companion.logger.debug("Skipping JWT check for {} request", shouldNotFilterUrl) + true + } else { + false + } + } + + private fun tokenFromHeader(httpRequest: HttpServletRequest): String? = + httpRequest.getHeader(HttpHeaders.AUTHORIZATION) + ?.takeIf { it.startsWith(AUTHORIZATION_BEARER_HEADER) } + ?.removePrefix(AUTHORIZATION_BEARER_HEADER) + ?.trim { it <= ' ' } + + @Throws(IOException::class) + private fun validateToken( + token: RadarToken?, + httpRequest: HttpServletRequest, + httpResponse: HttpServletResponse, + session: HttpSession?, + exMessage: String?, + ): Boolean { + return if (token != null) { + val updatedToken = checkUser(token, httpRequest, httpResponse, session) + ?: return false + httpRequest.radarToken = updatedToken + val authentication = RadarAuthentication(updatedToken) + authenticationManager.authenticate(authentication) + SecurityContextHolder.getContext().authentication = authentication + true + } else if (isOptional) { + logger.debug("Skipping optional token") + true + } else { + logger.error("Unauthorized - no valid token provided") + httpResponse.returnUnauthorized(httpRequest, exMessage) + false + } + } + + @Throws(IOException::class) + private fun checkUser( + token: RadarToken, + httpRequest: HttpServletRequest, + httpResponse: HttpServletResponse, + session: HttpSession?, + ): RadarToken? { + val userName = token.username ?: return token + val user = userRepository.findOneByLogin(userName) + return if (user.isPresent) { + token.copyWithRoles(user.get().authorityReferences) + } else { + session?.removeAttribute(TOKEN_ATTRIBUTE) + httpResponse.returnUnauthorized(httpRequest, "User not found") + null + } + } + + companion object { + private fun HttpServletResponse.returnUnauthorized(request: HttpServletRequest, message: String?) { + status = HttpServletResponse.SC_UNAUTHORIZED + setHeader(HttpHeaders.WWW_AUTHENTICATE, AUTHORIZATION_BEARER_HEADER) + val fullMessage = if (message != null) { + "\"$message\"" + } else { + "null" + } + outputStream.print( + """ + {"error": "Unauthorized", + "status": "${HttpServletResponse.SC_UNAUTHORIZED}", + message": $fullMessage, + "path": "${request.requestURI}"} + """.trimIndent() + ) + } + + private val logger = LoggerFactory.getLogger(JwtAuthenticationFilter::class.java) + private const val AUTHORIZATION_BEARER_HEADER = "Bearer" + private const val TOKEN_ATTRIBUTE = "jwt" + + /** + * Authority references for given user. The user should have its roles mapped + * from the database. + * @return set of authority references. + */ + val User.authorityReferences: Set + get() = roles.mapTo(HashSet()) { role: Role -> + val auth = role.role + val referent = when (auth.scope) { + RoleAuthority.Scope.GLOBAL -> null + RoleAuthority.Scope.ORGANIZATION -> role.organization.name + RoleAuthority.Scope.PROJECT -> role.project.projectName + } + AuthorityReference(auth, referent) + } + + + @get:JvmStatic + @set:JvmStatic + var HttpSession.radarToken: RadarToken? + get() = getAttribute(TOKEN_ATTRIBUTE) as RadarToken? + set(value) = setAttribute(TOKEN_ATTRIBUTE, value) + + @get:JvmStatic + @set:JvmStatic + var HttpServletRequest.radarToken: RadarToken? + get() = getAttribute(TOKEN_ATTRIBUTE) as RadarToken? + set(value) = setAttribute(TOKEN_ATTRIBUTE, value) + + val Authentication?.isAnonymous: Boolean + get() { + this ?: return true + return authorities.size == 1 && + authorities.firstOrNull()?.authority == "ROLE_ANONYMOUS" + } + } +} diff --git a/src/main/java/org/radarbase/management/security/jwt/RadarTokenLoader.kt b/src/main/java/org/radarbase/management/security/jwt/RadarTokenLoader.kt new file mode 100644 index 000000000..7f1ca59a8 --- /dev/null +++ b/src/main/java/org/radarbase/management/security/jwt/RadarTokenLoader.kt @@ -0,0 +1,20 @@ +package org.radarbase.management.security.jwt + +import org.radarbase.auth.token.RadarToken +import org.radarbase.management.security.JwtAuthenticationFilter.Companion.radarToken +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import javax.servlet.http.HttpServletRequest + +@Component +class RadarTokenLoader { + fun loadToken(httpServletRequest: HttpServletRequest): RadarToken? = + httpServletRequest.radarToken + ?.also { logger.debug("Using request RadarToken") } + ?: httpServletRequest.getSession(false)?.radarToken + ?.also { logger.debug("Using session RadarToken") } + + companion object { + private val logger = LoggerFactory.getLogger(RadarTokenLoader::class.java) + } +} diff --git a/src/main/java/org/radarbase/management/service/MetaTokenService.java b/src/main/java/org/radarbase/management/service/MetaTokenService.java index e82a4c338..61d96f361 100644 --- a/src/main/java/org/radarbase/management/service/MetaTokenService.java +++ b/src/main/java/org/radarbase/management/service/MetaTokenService.java @@ -84,7 +84,7 @@ public TokenDTO fetchToken(String tokenName) throws MalformedURLException { // process the response if the token is not fetched or not expired if (metaToken.isValid()) { String refreshToken = oAuthClientService.createAccessToken( - metaToken.getSubject(), + metaToken.getSubject().getUser(), metaToken.getClientId()) .getRefreshToken() .getValue(); diff --git a/src/main/java/org/radarbase/management/service/OAuthClientService.java b/src/main/java/org/radarbase/management/service/OAuthClientService.java index da1e6ffac..cf79f846d 100644 --- a/src/main/java/org/radarbase/management/service/OAuthClientService.java +++ b/src/main/java/org/radarbase/management/service/OAuthClientService.java @@ -1,6 +1,5 @@ package org.radarbase.management.service; -import org.radarbase.management.domain.Subject; import org.radarbase.management.domain.User; import org.radarbase.management.service.dto.ClientDetailsDTO; import org.radarbase.management.service.mapper.ClientDetailsMapper; @@ -158,14 +157,11 @@ public ClientDetails createClientDetail(ClientDetailsDTO clientDetailsDto) { * method bypasses the usual authorization code flow mechanism, so it should only be used where * appropriate, e.g., for subject impersonation. * - * @param clientId oauth client id. - * @param subject subject-id of the token. + * @param clientId oauth client id. + * @param user user of the token. * @return Created {@link OAuth2AccessToken} instance. */ - public OAuth2AccessToken createAccessToken(Subject subject, String clientId) { - // add the user's authorities - User user = subject.getUser(); - + public OAuth2AccessToken createAccessToken(User user, String clientId) { Set authorities = user.getAuthorities().stream() .map(a -> new SimpleGrantedAuthority(a.getName())) .collect(Collectors.toSet()); diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index 2522cd972..de8d8cac4 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -34,7 +34,7 @@ import javax.servlet.http.HttpSession; import javax.validation.Valid; -import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; +import static org.radarbase.management.security.JwtAuthenticationFilter.setRadarToken; import static org.radarbase.management.web.rest.errors.EntityName.USER; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_ACCESS_DENIED; import static org.radarbase.management.web.rest.errors.ErrorConstants.ERR_EMAIL_NOT_REGISTERED; @@ -97,7 +97,7 @@ public UserDTO login(HttpSession session) throws NotAuthorizedException { throw new NotAuthorizedException("Cannot login without credentials"); } log.debug("Logging in user to session with principal {}", token.getUsername()); - session.setAttribute(TOKEN_ATTRIBUTE, new DataRadarToken(token)); + setRadarToken(session, new DataRadarToken(token)); return getAccount(); } diff --git a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java index bd5b3042d..fd1e3afd9 100644 --- a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java @@ -38,7 +38,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.radarbase.management.security.JwtAuthenticationFilter.TOKEN_ATTRIBUTE; +import static org.radarbase.management.security.JwtAuthenticationFilter.setRadarToken; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -135,7 +135,7 @@ void testAuthenticatedUser() throws Exception { restUserMockMvc.perform(post("/api/login") .with(request -> { - request.setAttribute(TOKEN_ATTRIBUTE, token); + setRadarToken(request, token); request.setRemoteUser("test"); return request; }) From 8d9fd3663d6d6cee355d76d265f3352f4e39369f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 14:44:29 +0200 Subject: [PATCH 035/120] Bump versions --- build.gradle | 6 +++--- gradle.properties | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 122b244c4..df3ed766e 100644 --- a/build.gradle +++ b/build.gradle @@ -181,8 +181,8 @@ dependencies { implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugAnnotationVersion implementation("org.liquibase:liquibase-core:${liquibase_version}") runtimeOnly "com.mattbertolini:liquibase-slf4j:${liquibase_slf4j_version}" - implementation "org.springframework.boot:spring-boot-starter-actuator" - implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-autoconfigure") implementation "org.springframework.boot:spring-boot-starter-mail" runtimeOnly "org.springframework.boot:spring-boot-starter-logging" runtimeOnly ("org.springframework.boot:spring-boot-starter-data-jpa") { @@ -212,7 +212,7 @@ dependencies { implementation("org.springframework.session:spring-session-hazelcast") implementation('org.springframework.security.oauth:spring-security-oauth2:2.5.2.RELEASE') - implementation('org.springframework.security:spring-security-web:5.7.5') + implementation('org.springframework.security:spring-security-web:5.7.8') implementation "org.springdoc:springdoc-openapi-ui:${springdoc_version}" runtimeOnly("javax.inject:javax.inject:1") implementation project(':radar-auth') diff --git a/gradle.properties b/gradle.properties index 0042761f1..0a5ebbfc5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ liquibase_slf4j_version=4.1.0 liquibase_version=4.21.1 postgresql_version=42.6.0 springdoc_version=1.6.15 -spring_boot_version=2.7.10 +spring_boot_version=2.7.12 spring_framework_version=5.3.27 spring_data_version=2021.2.5 spring_session_version=2021.2.0 From eb10e936408b294a7a2b5d1e99c65a8eb721b212 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 15:13:41 +0200 Subject: [PATCH 036/120] Allow connecting the MP client to MP 1.0 with backwards compatible MPProject --- .../management/client/HttpStatusException.kt | 9 ++++++++ .../radarbase/management/client/MPClient.kt | 2 +- .../radarbase/management/client/MPProject.kt | 4 +++- .../management/client/MPProjectSerializer.kt | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/client/HttpStatusException.kt create mode 100644 managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/HttpStatusException.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/HttpStatusException.kt new file mode 100644 index 000000000..e11fcf835 --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/HttpStatusException.kt @@ -0,0 +1,9 @@ +package org.radarbase.management.client + +import io.ktor.http.* +import java.io.IOException + +class HttpStatusException( + val code: HttpStatusCode, + message: String, +) : IOException(message) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index 91d61d3f0..b71e9b0da 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -128,7 +128,7 @@ class MPClient(config: Config) { ): T = withContext(Dispatchers.IO) { with(httpClient.request(block)) { if (!status.isSuccess()) { - throw IOException("Request to ${request.url} failed (code $status)") + throw HttpStatusException(status, "Request to ${request.url} failed (code $status)") } body() } diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt index df2303934..a345dc17a 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** ManagementPortal Project DTO. */ -@Serializable +@Serializable(with = MPProjectSerializer::class) data class MPProject( /** Project id, a name that identifies it uniquely. */ @SerialName("projectName") val id: String, @@ -14,6 +14,8 @@ data class MPProject( val location: String? = null, /** Organization that organizes the project. */ val organization: MPOrganization? = null, + /** Free-text name of the organization. */ + val organizationName: String? = null, /** Project description. */ val description: String? = null, /** Any other attributes. */ diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt new file mode 100644 index 000000000..a3c1efab6 --- /dev/null +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt @@ -0,0 +1,23 @@ +package org.radarbase.management.client + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer + +class MPProjectSerializer : JsonTransformingSerializer(MPProject.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + val organization = element["organization"] + return if (organization == null || organization is JsonNull || organization is JsonObject) { + // MP 2.0 structure + element + } else { + // MP 0.x structure + val elementMap = element.toMutableMap() + elementMap["organization"] = JsonNull + elementMap["organizationName"] = organization + return JsonObject(elementMap) + } + } +} From 49c896417ad4632d42fc184e89ea5aaaed70e928 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 16:49:57 +0200 Subject: [PATCH 037/120] Fix custom MPProject serializer --- .../radarbase/management/client/MPClient.kt | 48 ++++++++---- .../radarbase/management/client/MPProject.kt | 2 +- .../management/client/MPProjectSerializer.kt | 2 +- .../management/client/MPClientTest.kt | 74 ++++++++++++++++++- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt index b71e9b0da..649dcd6f8 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPClient.kt @@ -23,11 +23,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual import org.radarbase.ktor.auth.OAuth2AccessToken -import java.io.IOException -import java.time.Duration import java.util.* +import kotlin.time.Duration.Companion.seconds fun mpClient(config: MPClient.Config.() -> Unit): MPClient { return MPClient(MPClient.Config().apply(config)) @@ -50,15 +52,12 @@ class MPClient(config: Config) { val httpClient = (originalHttpClient ?: HttpClient(CIO)).config { install(HttpTimeout) { - connectTimeoutMillis = Duration.ofSeconds(10).toMillis() - socketTimeoutMillis = Duration.ofSeconds(10).toMillis() - requestTimeoutMillis = Duration.ofSeconds(30).toMillis() + connectTimeoutMillis = 10.seconds.inWholeMilliseconds + socketTimeoutMillis = 10.seconds.inWholeMilliseconds + requestTimeoutMillis = 30.seconds.inWholeMilliseconds } install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - coerceInputValues = true - }) + json(json) } install(Auth) { token = auth() @@ -84,12 +83,15 @@ class MPClient(config: Config) { suspend fun requestProjects( page: Int = 0, size: Int = Int.MAX_VALUE, - ): List = request { - url("api/projects") - with(url.parameters) { - append("page", page.toString()) - append("size", size.toString()) + ): List { + val body = requestText { + url("api/projects") + with(url.parameters) { + append("page", page.toString()) + append("size", size.toString()) + } } + return json.decodeFromString(ListSerializer(MPProjectSerializer), body) } /** @@ -134,6 +136,17 @@ class MPClient(config: Config) { } } + suspend inline fun requestText( + crossinline block: HttpRequestBuilder.() -> Unit, + ): String = withContext(Dispatchers.IO) { + with(httpClient.request(block)) { + if (!status.isSuccess()) { + throw HttpStatusException(status, "Request to ${request.url} failed (code $status)") + } + bodyAsText() + } + } + fun config(config: Config.() -> Unit): MPClient { val oldConfig = toConfig() val newConfig = toConfig().apply(config) @@ -171,4 +184,11 @@ class MPClient(config: Config) { override fun hashCode(): Int = Objects.hash(httpClient, url) } + + companion object { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + } } diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt index a345dc17a..1e907398f 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProject.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** ManagementPortal Project DTO. */ -@Serializable(with = MPProjectSerializer::class) +@Serializable data class MPProject( /** Project id, a name that identifies it uniquely. */ @SerialName("projectName") val id: String, diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt index a3c1efab6..c6f97e097 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPProjectSerializer.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonTransformingSerializer -class MPProjectSerializer : JsonTransformingSerializer(MPProject.serializer()) { +object MPProjectSerializer : JsonTransformingSerializer(MPProject.serializer()) { override fun transformDeserialize(element: JsonElement): JsonElement { if (element !is JsonObject) return element val organization = element["organization"] diff --git a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt index 0ba119f0d..1bd54b3be 100644 --- a/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt +++ b/managementportal-client/src/test/kotlin/org/radarbase/management/client/MPClientTest.kt @@ -28,11 +28,9 @@ import org.junit.jupiter.api.Test import org.radarbase.ktor.auth.ClientCredentialsConfig import org.radarbase.ktor.auth.OAuth2AccessToken import org.radarbase.ktor.auth.clientCredentials -import org.slf4j.LoggerFactory import java.net.HttpURLConnection.HTTP_OK import java.net.HttpURLConnection.HTTP_UNAUTHORIZED -@OptIn(ExperimentalCoroutinesApi::class) class MPClientTest { private lateinit var authStub: StubMapping private lateinit var wireMockServer: WireMockServer @@ -243,7 +241,75 @@ class MPClientTest { wireMockServer.verify(2, getRequestedFor(urlPathEqualTo("/api/projects"))) } - companion object { - private val logger = LoggerFactory.getLogger(MPClientTest::class.java) + @Test + fun testMp1Projects() = runTest { + val body = """ + [{ + "id": 1, + "projectName": "A", + "humanReadableProjectName": null, + "description": "d", + "organization": "some", + "location": "l", + "startDate": "2020-01-01T00:00:00Z", + "projectStatus": "ONGOING", + "endDate": "2030-01-01T00:00:00Z", + "attributes": {}, + "persistentTokenTimeout": null + }, { + "id": 2, + "projectName": "a", + "humanReadableProjectName": "p", + "description": "D", + "organization": null, + "location": "L", + "startDate": "2020-01-01T00:00:00Z", + "projectStatus": "ONGOING", + "endDate": "2030-01-01T00:00:00Z", + "attributes": {}, + "persistentTokenTimeout": null + }] + """.trimIndent() + + wireMockServer.stubFor( + get(urlPathEqualTo("/api/projects")) + .withHeader("Authorization", equalTo("Bearer abcdef")) + .willReturn(aResponse() + .withStatus(HTTP_OK) + .withHeader("content-type", ContentType.APPLICATION_JSON.toString()) + .withBody(body))) + + + val projects = client.requestProjects() + assertThat(projects, hasSize(2)) + assertThat(projects, Matchers.equalTo(listOf( + MPProject( + id = "A", + name = null, + description = "d", + organization = null, + organizationName = "some", + location = "l", + startDate = "2020-01-01T00:00:00Z", + projectStatus = "ONGOING", + endDate = "2030-01-01T00:00:00Z", + attributes = emptyMap(), + ), + MPProject( + id = "a", + name = "p", + description = "D", + organization = null, + organizationName = null, + location = "L", + startDate = "2020-01-01T00:00:00Z", + projectStatus = "ONGOING", + endDate = "2030-01-01T00:00:00Z", + attributes = emptyMap(), + ), + ))) + + wireMockServer.verify(1, postRequestedFor(urlEqualTo("/oauth/token"))) + wireMockServer.verify(2, getRequestedFor(urlPathEqualTo("/api/projects"))) } } From 03b7957546416ecf5b5b0c75aff933a2935bdd44 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 16:51:58 +0200 Subject: [PATCH 038/120] Fix MPSubject with MP1.0 --- .../src/main/kotlin/org/radarbase/management/client/MPSubject.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt index 2548b9d95..3ad93e474 100644 --- a/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt +++ b/managementportal-client/src/main/kotlin/org/radarbase/management/client/MPSubject.kt @@ -11,6 +11,7 @@ data class MPSubject( /** Project id that the subject belongs to. */ @kotlinx.serialization.Transient val projectId: String? = null, /** Full project details that a subject belongs to. */ + @Serializable(with = MPProjectSerializer::class) val project: MPProject? = null, /** ID in an external system for the user. */ val externalId: String? = null, From e06feb4e2d5d41501641139d97ce9e1eb4f57ad3 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 28 Aug 2023 14:31:57 +0200 Subject: [PATCH 039/120] Disable sharing account token by default --- .../config/ManagementPortalProperties.java | 18 ++++++++++++++++++ .../management/web/rest/AccountResource.java | 4 +++- src/main/resources/config/application.yml | 2 ++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/radarbase/management/config/ManagementPortalProperties.java b/src/main/java/org/radarbase/management/config/ManagementPortalProperties.java index 84436f417..d004469b3 100644 --- a/src/main/java/org/radarbase/management/config/ManagementPortalProperties.java +++ b/src/main/java/org/radarbase/management/config/ManagementPortalProperties.java @@ -20,6 +20,8 @@ public class ManagementPortalProperties { private final CatalogueServer catalogueServer = new CatalogueServer(); + private final Account account = new Account(); + public ManagementPortalProperties.Frontend getFrontend() { return frontend; } @@ -44,6 +46,22 @@ public Common getCommon() { return common; } + public Account getAccount() { + return account; + } + + public static class Account { + private boolean enableExposeToken = false; + + public boolean getEnableExposeToken() { + return enableExposeToken; + } + + public void setEnableExposeToken(boolean enableExposeToken) { + this.enableExposeToken = enableExposeToken; + } + } + public static class Common { private String baseUrl = ""; diff --git a/src/main/java/org/radarbase/management/web/rest/AccountResource.java b/src/main/java/org/radarbase/management/web/rest/AccountResource.java index de8d8cac4..c9f959762 100644 --- a/src/main/java/org/radarbase/management/web/rest/AccountResource.java +++ b/src/main/java/org/radarbase/management/web/rest/AccountResource.java @@ -132,7 +132,9 @@ public UserDTO getAccount() { "Cannot get account without user", USER, ERR_ACCESS_DENIED)); UserDTO userDto = userMapper.userToUserDTO(currentUser); - userDto.setAccessToken(token.getToken()); + if (managementPortalProperties.getAccount().getEnableExposeToken()) { + userDto.setAccessToken(token.getToken()); + } return userDto; } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 841f4a5f4..dca3c7541 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -106,6 +106,8 @@ info: # # =================================================================== managementportal: + account: + enableExposeToken: false mail: # specific JHipster mail property, for standard properties see MailProperties from: ManagementPortal@localhost oauth: From 69d43cdbc8b8e4903482cb0a09b26e2d7469c6b6 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 29 Aug 2023 12:52:27 +0200 Subject: [PATCH 040/120] Fix account resource integration test --- .../management/web/rest/AccountResourceIntTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java index fd1e3afd9..64fe2e69e 100644 --- a/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java +++ b/src/test/java/org/radarbase/management/web/rest/AccountResourceIntTest.java @@ -9,6 +9,7 @@ import org.radarbase.auth.authorization.RoleAuthority; import org.radarbase.auth.token.RadarToken; import org.radarbase.management.ManagementPortalTestApp; +import org.radarbase.management.config.ManagementPortalProperties; import org.radarbase.management.domain.Authority; import org.radarbase.management.domain.User; import org.radarbase.management.repository.UserRepository; @@ -77,6 +78,9 @@ class AccountResourceIntTest { @Autowired private AuthService authService; + @Autowired + private ManagementPortalProperties managementPortalProperties; + @BeforeEach public void setUp() { MockitoAnnotations.initMocks(this); @@ -90,6 +94,8 @@ public void setUp() { ReflectionTestUtils.setField(accountResource, "mailService", mockMailService); ReflectionTestUtils.setField(accountResource, "authService", authService); ReflectionTestUtils.setField(accountResource, "token", radarToken); + ReflectionTestUtils.setField(accountResource, "managementPortalProperties", + managementPortalProperties); AccountResource accountUserMockResource = new AccountResource(); ReflectionTestUtils.setField(accountUserMockResource, "userService", mockUserService); @@ -97,6 +103,8 @@ public void setUp() { ReflectionTestUtils.setField(accountUserMockResource, "mailService", mockMailService); ReflectionTestUtils.setField(accountUserMockResource, "authService", authService); ReflectionTestUtils.setField(accountUserMockResource, "token", radarToken); + ReflectionTestUtils.setField(accountUserMockResource, "managementPortalProperties", + managementPortalProperties); this.restUserMockMvc = MockMvcBuilders.standaloneSetup(accountUserMockResource).build(); } From 96f9b3d9d82c2d75580f4c1af2d42886335f8ec2 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 29 Aug 2023 12:52:58 +0200 Subject: [PATCH 041/120] Bump versions --- build.gradle | 17 ++++++++--------- gradle.properties | 16 ++++++++-------- gradle/wrapper/gradle-wrapper.jar | Bin 62076 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 3 ++- gradlew | 8 ++++++-- managementportal-client/build.gradle | 6 +++--- 6 files changed, 27 insertions(+), 23 deletions(-) diff --git a/build.gradle b/build.gradle index df3ed766e..8148769c0 100644 --- a/build.gradle +++ b/build.gradle @@ -17,13 +17,13 @@ plugins { id 'application' id 'org.springframework.boot' version "${spring_boot_version}" id "com.github.node-gradle.node" version "3.6.0" - id "io.spring.dependency-management" version "1.1.0" - id 'de.undercouch.download' version '5.4.0' apply false + id "io.spring.dependency-management" version "1.1.3" + id 'de.undercouch.download' version '5.5.0' apply false id "io.github.gradle-nexus.publish-plugin" version "1.3.0" - id("com.github.ben-manes.versions") version "0.46.0" - id 'org.jetbrains.kotlin.jvm' version "1.8.21" - id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' apply false - id 'org.jetbrains.dokka' version "1.8.10" + id("com.github.ben-manes.versions") version "0.47.0" + id 'org.jetbrains.kotlin.jvm' version "1.9.10" + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' apply false + id 'org.jetbrains.dokka' version "1.8.20" } apply plugin: 'org.springframework.boot' @@ -45,12 +45,11 @@ allprojects { ext.githubRepoName = 'RADAR-base/ManagementPortal' ext.githubUrl = 'https://github.com/RADAR-base/ManagementPortal' - ext.issueUrl = "https://github.com/$githubRepoName/issues" ext.website = 'https://radar-base.org' repositories { mavenCentral() - maven { url = "https://oss.sonatype.org/content/repositories/snapshots" } +// maven { url = "https://oss.sonatype.org/content/repositories/snapshots" } } idea { @@ -261,7 +260,7 @@ tasks.register('cleanResources', Delete) { } wrapper { - gradleVersion '8.1.1' + gradleVersion '8.3' } tasks.register('stage') { diff --git a/gradle.properties b/gradle.properties index 0a5ebbfc5..58a00ba4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,29 +5,29 @@ jhipster_server_version=7.9.3 hazelcast_version=5.2.0 hikaricp_version=5.0.1 liquibase_slf4j_version=4.1.0 -liquibase_version=4.21.1 +liquibase_version=4.22.0 postgresql_version=42.6.0 springdoc_version=1.6.15 -spring_boot_version=2.7.12 +spring_boot_version=2.7.15 spring_framework_version=5.3.27 spring_data_version=2021.2.5 spring_session_version=2021.2.0 gatling_version=3.8.4 mapstruct_version=1.5.5.Final -jackson_version=2.15.0 +jackson_version=2.15.2 javax_xml_bind_version=2.3.3 javax_jaxb_core_version=2.3.0.1 javax_jaxb_runtime_version=2.3.8 javax_activation=1.1.1 mockito_version=4.8.1 slf4j_version=2.0.7 -logback_version=1.4.7 +logback_version=1.4.11 oauth_jwt_version=4.4.0 -junit_version=5.9.3 +junit_version=5.10.0 okhttp_version=4.10.0 -hsqldb_version=2.7.1 -coroutines_version=1.7.0 -ktor_version=2.3.0 +hsqldb_version=2.7.2 +coroutines_version=1.7.3 +ktor_version=2.3.3 kotlin.code.style=official org.gradle.vfs.watch=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79e29d3e0ab67b14947c167a862655af9b..7f93135c49b765f8051ef9d0a6055ff8e46073d8 100644 GIT binary patch delta 37652 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 delta 36122 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 Ac-xW1# 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 Q>rd#Eb%7 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37aef8d3f..ac72c34e8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index aeb74cbb4..0adc8e1a5 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -130,10 +131,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. diff --git a/managementportal-client/build.gradle b/managementportal-client/build.gradle index 6bb74c094..4e01cb691 100644 --- a/managementportal-client/build.gradle +++ b/managementportal-client/build.gradle @@ -23,8 +23,8 @@ targetCompatibility = JavaVersion.VERSION_11 description = "Kotlin ManagementPortal client" dependencies { - api("org.jetbrains.kotlin:kotlin-stdlib:1.8.21") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.21") + api("org.jetbrains.kotlin:kotlin-stdlib:1.9.10") + implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.10") implementation("org.radarbase:radar-commons-kotlin:1.0.0") api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:$coroutines_version")) @@ -50,7 +50,7 @@ tasks.withType(KotlinCompile).configureEach { compilerOptions { jvmTarget = JvmTarget.JVM_11 apiVersion = KotlinVersion.KOTLIN_1_7 - languageVersion = KotlinVersion.KOTLIN_1_8 + languageVersion = KotlinVersion.KOTLIN_1_9 } } From 4f74f74922ef2c6bbd81a738aeb72c9e3639e38a Mon Sep 17 00:00:00 2001 From: Bastiaan Date: Fri, 8 Sep 2023 09:42:13 +0200 Subject: [PATCH 042/120] Removed a block of code from build.gradle that was not needed and blocking successful build on windows --- build.gradle | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/build.gradle b/build.gradle index 8148769c0..14e162bdf 100644 --- a/build.gradle +++ b/build.gradle @@ -49,7 +49,6 @@ allprojects { repositories { mavenCentral() -// maven { url = "https://oss.sonatype.org/content/repositories/snapshots" } } idea { @@ -97,31 +96,9 @@ springBoot { buildInfo() } -if (OperatingSystem.current().isWindows()) { - tasks.register('pathingJar', Jar) { - dependsOn configurations.runtime - archiveAppendix.set('pathing') - doFirst { - manifest { - attributes 'Class-Path': configurations.runtime.files.collect { - it.toURI().toURL().toString().replaceFirst(/file:\/+/, '/').replaceAll(' ', '%20') - }.join(' ') - } - } - } - - bootRun { - sourceResources sourceSets.main - dependsOn pathingJar - doFirst { - classpath = files("$buildDir/classes/java/main", "$buildDir/resources/main", pathingJar.archivePath) - } - } -} else { - bootRun { - sourceResources sourceSets.main - } +bootRun { + sourceResources sourceSets.main } tasks.withType(KotlinCompile).configureEach { From e9bc5e53054f8dcf1676d788c1b0642aa02256af Mon Sep 17 00:00:00 2001 From: Bastiaan Date: Fri, 8 Sep 2023 09:43:32 +0200 Subject: [PATCH 043/120] added "NODE_OPTIONS=--openssl-legacy-provider" flag to start script to fix error caused by node update. see https://stackoverflow.com/a/69699772 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8aafcdc32..46ba49696 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "lint": "ng lint", "lint:fix": "ng lint --fix=true", "cleanup": "rimraf build/", - "start": "ng serve", + "start": "NODE_OPTIONS=--openssl-legacy-provider ng serve", "build:prod": "ng build --base-href /managementportal/ --configuration production", "build:dev": "ng build --configuration development", "test": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI", From 07aa65f01231ea43e29079d187d1ce9bcaf8d5f9 Mon Sep 17 00:00:00 2001 From: Bastiaan Date: Wed, 6 Sep 2023 16:06:07 +0200 Subject: [PATCH 044/120] Added requested fields from issue #706 --- .../subject-pair-dialog.component.html | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/app/shared/subject/subject-pair-dialog.component.html b/src/main/webapp/app/shared/subject/subject-pair-dialog.component.html index 7c58c9042..4a4053124 100644 --- a/src/main/webapp/app/shared/subject/subject-pair-dialog.component.html +++ b/src/main/webapp/app/shared/subject/subject-pair-dialog.component.html @@ -10,10 +10,28 @@