diff --git a/build.gradle b/build.gradle index 17a1fa37..fdcf96d6 100644 --- a/build.gradle +++ b/build.gradle @@ -111,6 +111,7 @@ dependencies { testImplementation libs.groovy testImplementation libs.bundles.testcontainers testImplementation libs.bundles.junit + testImplementation libs.mockito.inline testImplementation libs.mockito.kotlin testImplementation(libs.spring.boot.starter.test) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 360360b5..b2d479a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -128,6 +128,8 @@ brave-instrumentation-grpc = "io.zipkin.brave:brave-instrumentation-grpc:5.15.0" auth0-jwt = "com.auth0:java-jwt:4.4.0" +mockito-inline = "org.mockito:mockito-inline:4.0.0" + [bundles] apache-commons = ["commons-io", "apache-commons-lang3", "apache-commons-collections4"] etherjar = ["etherjar-domain", "etherjar-hex", "etherjar-rpc-api", "etherjar-rpc-http", "etherjar-rpc-ws", "etherjar-tx", "etherjar-contract", "etherjar-erc20"] diff --git a/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt b/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt index 1b7ebc84..5f440ccc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/GrpcServer.kt @@ -84,7 +84,7 @@ open class GrpcServer( } serverBuilder.intercept(grpcServerBraveInterceptor) - if (mainConfig.authorization.enabled) { + if (mainConfig.authorization.enabled && mainConfig.authorization.hasServerConfig()) { serverBuilder.intercept(authInterceptor) log.info("Token authorization is turned on") } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/auth/service/AuthService.kt b/src/main/kotlin/io/emeraldpay/dshackle/auth/service/AuthService.kt index 6f761ee1..bb43ea8d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/auth/service/AuthService.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/auth/service/AuthService.kt @@ -22,7 +22,8 @@ class AuthService( } val keys = rsaKeyReader.getKeyPair( - authorizationConfig.providerPrivateKeyPath, authorizationConfig.externalPublicKeyPath + authorizationConfig.serverConfig.providerPrivateKeyPath, + authorizationConfig.serverConfig.externalPublicKeyPath ) val decodedJwt = JWT.decode(token) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt index 5e28aec5..e93d32ce 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfig.kt @@ -39,6 +39,10 @@ class AuthConfig { var key: String? = null ) : ClientAuth() + class ClientTokenAuth( + var publicKeyPath: String? = null + ) + /** * Example config: * ``` diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt index 8ef4b542..28628ed2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthConfigReader.kt @@ -49,6 +49,14 @@ class AuthConfigReader : YamlConfigReader() { } } + fun readTokenAuth(node: MappingNode?): AuthConfig.ClientTokenAuth? { + return getMapping(node, "token-auth")?.let { + val auth = AuthConfig.ClientTokenAuth() + auth.publicKeyPath = getValueAsString(it, "public-key") + auth + } + } + /** * Example config: * ``` diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfig.kt index e3ee5bea..222c4538 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfig.kt @@ -3,12 +3,37 @@ package io.emeraldpay.dshackle.config data class AuthorizationConfig( val enabled: Boolean, val publicKeyOwner: String, - val providerPrivateKeyPath: String, - val externalPublicKeyPath: String + val serverConfig: ServerConfig, + val clientConfig: ClientConfig ) { + fun hasServerConfig() = serverConfig != ServerConfig.default() + companion object { @JvmStatic - fun default() = AuthorizationConfig(false, "", "", "") + fun default() = AuthorizationConfig( + false, "", + ServerConfig.default(), + ClientConfig.default() + ) + } + + data class ServerConfig( + val providerPrivateKeyPath: String, + val externalPublicKeyPath: String + ) { + companion object { + @JvmStatic + fun default() = ServerConfig("", "") + } + } + + data class ClientConfig( + val privateKeyPath: String, + ) { + companion object { + @JvmStatic + fun default() = ClientConfig("") + } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfigReader.kt index ef54e929..ed1ebe2e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/AuthorizationConfigReader.kt @@ -27,20 +27,38 @@ class AuthorizationConfigReader : YamlConfigReader() { val publicKeyOwner = getValueAsString(auth, "publicKeyOwner") ?: throw IllegalStateException("Public key owner in not specified") - val keyPair = getMapping(auth, "keys") ?: throw IllegalStateException("Auth keys is not specified") - val privateKey = getValueAsString(keyPair, "provider-private-key") - ?: throw IllegalStateException("Private key in not specified") - val publicKey = getValueAsString(keyPair, "external-public-key") - ?: throw IllegalStateException("External key in not specified") + val authServer = getMapping(auth, "server") + ?.run { + val keyPair = getMapping(this, "keys") + ?: throw IllegalStateException("Auth keys is not specified") + val privateKey = getValueAsString(keyPair, "provider-private-key") + ?: throw IllegalStateException("Private key in not specified") + val publicKey = getValueAsString(keyPair, "external-public-key") + ?: throw IllegalStateException("External key in not specified") - if (fileNotExists(privateKey)) { - throw IllegalStateException("There is no such file: $privateKey") - } - if (fileNotExists(publicKey)) { - throw IllegalStateException("There is no such file: $publicKey") + if (fileNotExists(privateKey)) { + throw IllegalStateException("There is no such file: $privateKey") + } + if (fileNotExists(publicKey)) { + throw IllegalStateException("There is no such file: $publicKey") + } + AuthorizationConfig.ServerConfig(privateKey, publicKey) + } + + val authClient = getMapping(auth, "client") + ?.run { + AuthorizationConfig.ClientConfig(getValueAsString(this, "private-key")!!) + } + + if (authClient == null && authServer == null) { + throw IllegalStateException("Token auth server settings are not specified") } - return AuthorizationConfig(enabled, publicKeyOwner, privateKey, publicKey) + return AuthorizationConfig( + enabled, publicKeyOwner, + authServer ?: AuthorizationConfig.ServerConfig.default(), + authClient ?: AuthorizationConfig.ClientConfig.default() + ) } private fun fileNotExists(path: String): Boolean { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt index 171c1554..7fea9b8d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfig.kt @@ -150,6 +150,7 @@ open class UpstreamsConfig { var host: String? = null var port: Int = 0 var auth: AuthConfig.ClientTlsAuth? = null + var tokenAuth: AuthConfig.ClientTokenAuth? = null var upstreamRating: Int = 0 } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt index 03d69a9f..830c7116 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt @@ -114,6 +114,7 @@ class UpstreamsConfigReader( connection.port = it } connection.auth = authConfigReader.readClientTls(connConfigNode) + connection.tokenAuth = authConfigReader.readTokenAuth(connConfigNode) } else { log.error("Upstream at #0 has invalid configuration") } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/ConfiguredUpstreams.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/ConfiguredUpstreams.kt index a71d53fe..153ed7cc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/ConfiguredUpstreams.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/ConfiguredUpstreams.kt @@ -23,6 +23,7 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Defaults import io.emeraldpay.dshackle.FileResolver import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.AuthorizationConfig import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.config.CompressionConfig import io.emeraldpay.dshackle.config.UpstreamsConfig @@ -51,6 +52,7 @@ import io.emeraldpay.dshackle.upstream.forkchoice.ForkChoice import io.emeraldpay.dshackle.upstream.forkchoice.MostWorkForkChoice import io.emeraldpay.dshackle.upstream.forkchoice.NoChoiceWithPriorityForkChoice import io.emeraldpay.dshackle.upstream.grpc.GrpcUpstreams +import io.emeraldpay.dshackle.upstream.grpc.auth.GrpcAuthContext import io.grpc.ClientInterceptor import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -84,7 +86,9 @@ open class ConfiguredUpstreams( @Autowired(required = false) private val clientSpansInterceptor: ClientInterceptor?, @Qualifier("headScheduler") - private val headScheduler: Scheduler + private val headScheduler: Scheduler, + private val authorizationConfig: AuthorizationConfig, + private val grpcAuthContext: GrpcAuthContext ) : ApplicationRunner { @Value("\${spring.application.max-metadata-size}") private var maxMetadataSize: Int = Defaults.maxMetadataSize @@ -351,6 +355,8 @@ open class ConfiguredUpstreams( endpoint.host!!, endpoint.port, endpoint.auth, + endpoint.tokenAuth, + authorizationConfig, compression, fileResolver, endpoint.upstreamRating, @@ -361,7 +367,8 @@ open class ConfiguredUpstreams( grpcTracing, clientSpansInterceptor, maxMetadataSize, - headScheduler + headScheduler, + grpcAuthContext ).apply { timeout = options.timeout } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt index 659a9cb5..bab15479 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/GrpcUpstreams.kt @@ -22,22 +22,30 @@ import io.emeraldpay.api.proto.BlockchainOuterClass.DescribeResponse import io.emeraldpay.api.proto.BlockchainOuterClass.StatusRequest import io.emeraldpay.api.proto.Common import io.emeraldpay.api.proto.Common.ChainRef.UNRECOGNIZED +import io.emeraldpay.api.proto.ReactorAuthGrpc import io.emeraldpay.api.proto.ReactorBlockchainGrpc import io.emeraldpay.dshackle.BlockchainType import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Defaults import io.emeraldpay.dshackle.FileResolver import io.emeraldpay.dshackle.config.AuthConfig +import io.emeraldpay.dshackle.config.AuthorizationConfig import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.startup.UpstreamChangeEvent import io.emeraldpay.dshackle.upstream.DefaultUpstream import io.emeraldpay.dshackle.upstream.Lifecycle import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.grpc.auth.AuthException +import io.emeraldpay.dshackle.upstream.grpc.auth.ClientAuthenticationInterceptor +import io.emeraldpay.dshackle.upstream.grpc.auth.GrpcAuthContext +import io.emeraldpay.dshackle.upstream.grpc.auth.GrpcUpstreamsAuth import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcGrpcClient import io.emeraldpay.dshackle.upstream.rpcclient.RpcMetrics import io.grpc.ClientInterceptor import io.grpc.Codec +import io.grpc.Status +import io.grpc.StatusRuntimeException import io.grpc.netty.NettyChannelBuilder import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Metrics @@ -52,6 +60,7 @@ import org.apache.commons.lang3.exception.ExceptionUtils import org.slf4j.LoggerFactory import reactor.core.Disposable import reactor.core.publisher.Flux +import reactor.core.publisher.Mono import reactor.core.scheduler.Scheduler import java.io.IOException import java.time.Duration @@ -66,6 +75,8 @@ class GrpcUpstreams( private val host: String, private val port: Int, private val auth: AuthConfig.ClientTlsAuth? = null, + private val tokenAuth: AuthConfig.ClientTokenAuth? = null, + private val authorizationConfig: AuthorizationConfig, private val compression: Boolean, private val fileResolver: FileResolver, private val nodeRating: Int, @@ -76,7 +87,8 @@ class GrpcUpstreams( private val grpcTracing: GrpcTracing, private val clientSpansInterceptor: ClientInterceptor?, private var maxMetadataSize: Int, - private val headScheduler: Scheduler + private val headScheduler: Scheduler, + private val grpcAuthContext: GrpcAuthContext ) { private val log = LoggerFactory.getLogger(GrpcUpstreams::class.java) @@ -92,7 +104,10 @@ class GrpcUpstreams( .maxInboundMessageSize(Defaults.maxMessageSize) .maxInboundMetadataSize(maxMetadataSize) .enableRetry() - .intercept(grpcTracing.newClientInterceptor()) + .intercept( + grpcTracing.newClientInterceptor(), + ClientAuthenticationInterceptor(id, grpcAuthContext), + ) .executor(grpcExecutor) .maxRetryAttempts(3) clientSpansInterceptor?.let { @@ -108,17 +123,28 @@ class GrpcUpstreams( chanelBuilder.usePlaintext() } - var client = ReactorBlockchainGrpc.newReactorStub(chanelBuilder.build()) + val channel = chanelBuilder.build() + var client = ReactorBlockchainGrpc.newReactorStub(channel) if (compression) { client = client.withCompression(Codec.Gzip().messageEncoding) } this.client = client + val grpcUpstreamsAuth = + if (tokenAuth != null && authorizationConfig.enabled) { + GrpcUpstreamsAuth( + ReactorAuthGrpc.newReactorStub(channel), + authorizationConfig, + grpcAuthContext, + tokenAuth.publicKeyPath!! + ) + } else null + val statusSubscriptions = mutableMapOf() return Flux.interval(Duration.ZERO, Duration.ofSeconds(20)) .flatMap { - client.describe(DescribeRequest.newBuilder().build()) + authAndDescribe(grpcUpstreamsAuth) }.onErrorContinue { t, _ -> if (ExceptionUtils.indexOfType(t, IOException::class.java) >= 0) { log.warn("gRPC upstream $host:$port is unavailable. (${t.javaClass}: ${t.message})") @@ -133,7 +159,7 @@ class GrpcUpstreams( }.doOnNext { val sub = statusSubscriptions[it.chain] if (sub == null || sub.isDisposed) { - val subscription = client.subscribeStatus( + val subscription = this.client.subscribeStatus( StatusRequest.newBuilder() .addChains(Common.ChainRef.forNumber(it.chain.id)).build() ).subscribeOn(chainStatusScheduler) @@ -293,4 +319,43 @@ class GrpcUpstreams( } } } + + private fun authAndDescribe(grpcUpstreamsAuth: GrpcUpstreamsAuth?): Mono { + return Mono.justOrEmpty(grpcUpstreamsAuth) + .flatMap { + if (grpcAuthContext.containsToken(id)) { + Mono.empty() + } else { + auth(it) + } + } + .then( + describe() + .onErrorResume { + if (it is StatusRuntimeException && it.status.code == Status.Code.UNAUTHENTICATED) { + grpcAuthContext.removeToken(id) + auth(grpcUpstreamsAuth).then(describe()) + } else { + Mono.error(it) + } + } + ) + } + + private fun auth(grpcUpstreamsAuth: GrpcUpstreamsAuth?): Mono { + if (grpcUpstreamsAuth == null) { + return Mono.empty() + } + return grpcUpstreamsAuth.auth(id) + .flatMap { authRes -> + if (!authRes.passed) { + log.warn(authRes.cause) + Mono.error(AuthException(authRes.cause!!)) + } else { + Mono.empty() + } + }.then() + } + + private fun describe() = this.client.describe(DescribeRequest.newBuilder().build()) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/ClientAuthenticationInterceptor.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/ClientAuthenticationInterceptor.kt new file mode 100644 index 00000000..270095ca --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/ClientAuthenticationInterceptor.kt @@ -0,0 +1,35 @@ +package io.emeraldpay.dshackle.upstream.grpc.auth + +import io.emeraldpay.dshackle.auth.processor.SESSION_ID +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.ForwardingClientCall +import io.grpc.Metadata +import io.grpc.MethodDescriptor + +class ClientAuthenticationInterceptor( + private val upstreamId: String, + private val grpcAuthContext: GrpcAuthContext +) : ClientInterceptor { + + companion object { + private val AUTHORIZATION_HEADER: Metadata.Key = + Metadata.Key.of(SESSION_ID, Metadata.ASCII_STRING_MARSHALLER) + } + + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel + ): ClientCall = + object : ForwardingClientCall.SimpleForwardingClientCall(next.newCall(method, callOptions)) { + override fun start(responseListener: Listener, headers: Metadata) { + grpcAuthContext.getToken(upstreamId)?.let { + headers.put(AUTHORIZATION_HEADER, it) + } + super.start(responseListener, headers) + } + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcAuthContext.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcAuthContext.kt new file mode 100644 index 00000000..694b2908 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcAuthContext.kt @@ -0,0 +1,21 @@ +package io.emeraldpay.dshackle.upstream.grpc.auth + +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap + +@Component +class GrpcAuthContext { + private val sessions = ConcurrentHashMap() + + fun putTokenInContext(upstreamId: String, sessionId: String) { + sessions[upstreamId] = sessionId + } + + fun removeToken(upstreamId: String) { + sessions.remove(upstreamId) + } + + fun containsToken(upstreamId: String) = sessions.containsKey(upstreamId) + + fun getToken(upstreamId: String) = sessions[upstreamId] +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuth.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuth.kt new file mode 100644 index 00000000..89f41596 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuth.kt @@ -0,0 +1,64 @@ +package io.emeraldpay.dshackle.upstream.grpc.auth + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import io.emeraldpay.api.proto.AuthOuterClass +import io.emeraldpay.api.proto.ReactorAuthGrpc.ReactorAuthStub +import io.emeraldpay.dshackle.auth.processor.AuthVersion +import io.emeraldpay.dshackle.auth.processor.SESSION_ID +import io.emeraldpay.dshackle.auth.processor.VERSION +import io.emeraldpay.dshackle.auth.service.RsaKeyReader +import io.emeraldpay.dshackle.config.AuthorizationConfig +import reactor.core.publisher.Mono +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Instant + +class AuthException(message: String) : RuntimeException(message) + +class GrpcUpstreamsAuth( + private val authClient: ReactorAuthStub, + private val authorizationConfig: AuthorizationConfig, + private val grpcAuthContext: GrpcAuthContext, + publicKeyPath: String +) { + private val rsaKeyReader = RsaKeyReader() + private val keys = rsaKeyReader.getKeyPair(authorizationConfig.clientConfig.privateKeyPath, publicKeyPath) + + fun auth(providerId: String): Mono { + return authClient.authenticate( + AuthOuterClass.AuthRequest.newBuilder() + .setToken(generateToken()) + .build() + ).map { + verify(it.providerToken, providerId) + }.onErrorResume { + Mono.just(AuthResult(false, "Error during auth - ${it.message}")) + } + } + + private fun generateToken(): String { + return JWT.create() + .withIssuedAt(Instant.now()) + .withIssuer(authorizationConfig.publicKeyOwner) + .withClaim(VERSION, AuthVersion.V1.toString()) + .sign(Algorithm.RSA256(keys.providerPrivateKey as RSAPrivateKey)) + } + + private fun verify(token: String, providerId: String): AuthResult { + val verifier: JWTVerifier = JWT + .require(Algorithm.RSA256(keys.externalPublicKey as RSAPublicKey, null)) + .withClaim(SESSION_ID) { claim, _ -> !claim.isMissing } + .build() + val decodedToken = verifier.verify(token) + grpcAuthContext.putTokenInContext(providerId, decodedToken.getClaim(SESSION_ID).asString()) + + return AuthResult(true) + } + + data class AuthResult( + val passed: Boolean, + val cause: String? = null + ) +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/AuthorizationConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/AuthorizationConfigReaderSpec.groovy index 77793be5..391688d7 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/config/AuthorizationConfigReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/config/AuthorizationConfigReaderSpec.groovy @@ -28,8 +28,8 @@ class AuthorizationConfigReaderSpec extends Specification { def act = AuthorizationConfig.default() then: !act.enabled - act.externalPublicKeyPath == "" - act.providerPrivateKeyPath == "" + act.serverConfig.externalPublicKeyPath == "" + act.serverConfig.providerPrivateKeyPath == "" } def "exceptions if no settings"() { @@ -48,5 +48,15 @@ class AuthorizationConfigReaderSpec extends Specification { "configs/auth-without-key-owner.yaml" | "Public key owner in not specified" "configs/auth-with-wrong-priv-key.yaml" | "There is no such file: classpath:keys/priv-wrong.p8.key" "configs/auth-with-wrong-pub-key.yaml" | "There is no such file: classpath:keys/pub-wrong.key" + "configs/auth-without-any-config.yaml" | "Token auth server settings are not specified" + } + + def "client settings is correct"() { + setup: + def yamlIs = this.class.getClassLoader().getResourceAsStream("configs/auth-with-client-settings.yaml") + when: + def act = reader.read(yamlIs) + then: + act.clientConfig == new AuthorizationConfig.ClientConfig("classpath:keys/priv-wrong.p8.key") } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/MainConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/MainConfigReaderSpec.groovy index e5bcd15a..d9c47278 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/config/MainConfigReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/config/MainConfigReaderSpec.groovy @@ -91,22 +91,29 @@ class MainConfigReaderSpec extends Specification { chains == ["ethereum"] options.minPeers == 3 } - upstreams.size() == 3 + upstreams.size() == 4 with(upstreams[0]) { id == "remote" } with(upstreams[1]) { - id == "local" + id == "remoteTokenAuth" + connection instanceof UpstreamsConfig.GrpcConnection + with(connection as UpstreamsConfig.GrpcConnection) { + it.tokenAuth.publicKeyPath == "/path/to/key.pem" + } } with(upstreams[2]) { + id == "local" + } + with(upstreams[3]) { id == "infura" } } act.authorization != null with(act.authorization) { enabled - providerPrivateKeyPath == "classpath:keys/priv.p8.key" - externalPublicKeyPath == "classpath:keys/public.pem" + serverConfig.providerPrivateKeyPath == "classpath:keys/priv.p8.key" + serverConfig.externalPublicKeyPath == "classpath:keys/public.pem" } } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/startup/ConfiguredUpstreamsSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/startup/ConfiguredUpstreamsSpec.groovy index eb392af0..d0968526 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/startup/ConfiguredUpstreamsSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/startup/ConfiguredUpstreamsSpec.groovy @@ -4,12 +4,14 @@ import brave.Tracing import brave.grpc.GrpcTracing import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.FileResolver +import io.emeraldpay.dshackle.config.AuthorizationConfig import io.emeraldpay.dshackle.config.ChainsConfig import io.emeraldpay.dshackle.config.CompressionConfig import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.quorum.NotNullQuorum import io.emeraldpay.dshackle.upstream.CallTargetsHolder import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods +import io.emeraldpay.dshackle.upstream.grpc.auth.GrpcAuthContext import org.springframework.context.ApplicationEventPublisher import reactor.core.scheduler.Schedulers import spock.lang.Specification @@ -33,6 +35,8 @@ class ConfiguredUpstreamsSpec extends Specification { Schedulers.boundedElastic(), null, Schedulers.boundedElastic(), + AuthorizationConfig.default(), + new GrpcAuthContext() ) def methods = new UpstreamsConfig.Methods( [ @@ -65,6 +69,8 @@ class ConfiguredUpstreamsSpec extends Specification { Schedulers.boundedElastic(), null, Schedulers.boundedElastic(), + AuthorizationConfig.default(), + new GrpcAuthContext() ) def methods = new UpstreamsConfig.Methods( [ @@ -96,6 +102,8 @@ class ConfiguredUpstreamsSpec extends Specification { Schedulers.boundedElastic(), null, Schedulers.boundedElastic(), + AuthorizationConfig.default(), + new GrpcAuthContext() ) expect: configurer.getHash(node, src) == expected @@ -122,6 +130,8 @@ class ConfiguredUpstreamsSpec extends Specification { Schedulers.boundedElastic(), null, Schedulers.boundedElastic(), + AuthorizationConfig.default(), + new GrpcAuthContext() ) when: def h1 = configurer.getHash(null, "hohoho") @@ -153,6 +163,8 @@ class ConfiguredUpstreamsSpec extends Specification { Schedulers.boundedElastic(), null, Schedulers.boundedElastic(), + AuthorizationConfig.default(), + new GrpcAuthContext() ) def methodsGroup = new UpstreamsConfig.MethodGroups( ["filter"] as Set, diff --git a/src/test/kotlin/io/emeraldpay/dshackle/auth/processor/AuthProcessorV1Test.kt b/src/test/kotlin/io/emeraldpay/dshackle/auth/processor/AuthProcessorV1Test.kt index 811ccb35..e6752b61 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/auth/processor/AuthProcessorV1Test.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/auth/processor/AuthProcessorV1Test.kt @@ -22,7 +22,13 @@ import java.security.interfaces.RSAPublicKey import java.security.spec.X509EncodedKeySpec class AuthProcessorV1Test { - private val processor = AuthProcessorV1(AuthorizationConfig(true, "drpc", "", "")) + private val processor = AuthProcessorV1( + AuthorizationConfig( + true, "drpc", + AuthorizationConfig.ServerConfig.default(), + AuthorizationConfig.ClientConfig.default() + ) + ) private val rsaKeyReader = RsaKeyReader() private val privProviderPath = ResourceUtils.getFile("classpath:keys/priv.p8.key").path private val publicDrpcPath = ResourceUtils.getFile("classpath:keys/public-drpc.pem").path diff --git a/src/test/kotlin/io/emeraldpay/dshackle/auth/service/AuthServiceTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/auth/service/AuthServiceTest.kt index b985ef56..3ed85cd8 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/auth/service/AuthServiceTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/auth/service/AuthServiceTest.kt @@ -42,7 +42,14 @@ class AuthServiceTest { val tokenWrapper = AuthContext.TokenWrapper( "token", Instant.now(), "sessionId" ) - val authService = AuthService(AuthorizationConfig(true, "drpc", "privPath", "pubPath"), rsaKeyReader, factory) + val authService = AuthService( + AuthorizationConfig( + true, "drpc", + AuthorizationConfig.ServerConfig("privPath", "pubPath"), + AuthorizationConfig.ClientConfig.default() + ), + rsaKeyReader, factory + ) val pair = KeyReader.Keys(mock(PrivateKey::class.java), mock(PublicKey::class.java)) `when`(rsaKeyReader.getKeyPair("privPath", "pubPath")) @@ -64,7 +71,14 @@ class AuthServiceTest { "token", Instant.now(), "sessionIdNext" ) val pair = KeyReader.Keys(mock(PrivateKey::class.java), mock(PublicKey::class.java)) - val authService = AuthService(AuthorizationConfig(true, "drpc", "privPath", "pubPath"), rsaKeyReader, factory) + val authService = AuthService( + AuthorizationConfig( + true, "drpc", + AuthorizationConfig.ServerConfig("privPath", "pubPath"), + AuthorizationConfig.ClientConfig.default() + ), + rsaKeyReader, factory + ) `when`(rsaKeyReader.getKeyPair("privPath", "pubPath")).thenReturn(pair) `when`(mockV1Processor.process(pair, token)) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuthTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuthTest.kt new file mode 100644 index 00000000..2ebc1be0 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/grpc/auth/GrpcUpstreamsAuthTest.kt @@ -0,0 +1,162 @@ +package io.emeraldpay.dshackle.upstream.grpc.auth + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.emeraldpay.api.proto.AuthOuterClass +import io.emeraldpay.api.proto.AuthOuterClass.AuthRequest +import io.emeraldpay.api.proto.ReactorAuthGrpc.ReactorAuthStub +import io.emeraldpay.dshackle.auth.processor.SESSION_ID +import io.emeraldpay.dshackle.config.AuthorizationConfig +import org.bouncycastle.openssl.PEMParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.`when` +import org.springframework.util.ResourceUtils +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.io.StringReader +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.interfaces.RSAPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Duration +import java.util.UUID + +class GrpcUpstreamsAuthTest { + private val privateKeyPath = ResourceUtils.getFile("classpath:keys/priv-drpc.p8.key").path + private val providerPublicKeyPath = ResourceUtils.getFile("classpath:keys/public.pem").path + private val grpcAuthContext = GrpcAuthContext() + private val authConfig = AuthorizationConfig( + true, "drpc", + AuthorizationConfig.ServerConfig.default(), + AuthorizationConfig.ClientConfig(privateKeyPath) + ) + + private val providerPrivateKeyPath = ResourceUtils.getFile("classpath:keys/priv.p8.key").path + + private val upstreamId = "providerId" + + @BeforeEach + fun clearSessions() { + grpcAuthContext.removeToken(upstreamId) + } + + @Test + fun `success auth`() { + val sessionId = UUID.randomUUID().toString() + val authStub = Mockito.mock(ReactorAuthStub::class.java) + val grpcAuth = GrpcUpstreamsAuth(authStub, authConfig, grpcAuthContext, providerPublicKeyPath) + val token = JWT.create() + .withClaim(SESSION_ID, sessionId) + .sign(Algorithm.RSA256(generatePrivateKey(providerPrivateKeyPath) as RSAPrivateKey)) + + `when`(authStub.authenticate(any(AuthRequest::class.java))) + .thenReturn( + Mono.just( + AuthOuterClass.AuthResponse.newBuilder() + .setProviderToken(token) + .build() + ) + ) + + val result = grpcAuth.auth(upstreamId) + + StepVerifier.create(result) + .expectNext(GrpcUpstreamsAuth.AuthResult(true)) + .then { + assertEquals(sessionId, grpcAuthContext.getToken(upstreamId)) + } + .expectComplete() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `auth is failed`() { + val providerId = "providerId" + val sessionId = UUID.randomUUID().toString() + val authStub = Mockito.mock(ReactorAuthStub::class.java) + val grpcAuth = GrpcUpstreamsAuth(authStub, authConfig, grpcAuthContext, providerPublicKeyPath) + val token = JWT.create() + .withClaim(SESSION_ID, sessionId) + .sign(Algorithm.RSA256(generatePrivateKey(privateKeyPath) as RSAPrivateKey)) + + `when`(authStub.authenticate(any(AuthRequest::class.java))) + .thenReturn( + Mono.just( + AuthOuterClass.AuthResponse.newBuilder() + .setProviderToken(token) + .build() + ) + ) + + val result = grpcAuth.auth(providerId) + + StepVerifier.create(result) + .expectNext( + GrpcUpstreamsAuth.AuthResult( + false, + "Error during auth - The Token's Signature resulted invalid when verified using the Algorithm: SHA256withRSA" + ) + ) + .then { + assertEquals(null, grpcAuthContext.getToken(upstreamId)) + } + .expectComplete() + .verify(Duration.ofSeconds(3)) + } + + @Test + fun `replace sessionId for the same provider`() { + val providerId = "providerId" + val sessionId = UUID.randomUUID().toString() + val sessionId1 = UUID.randomUUID().toString() + val authStub = Mockito.mock(ReactorAuthStub::class.java) + val grpcAuth = GrpcUpstreamsAuth(authStub, authConfig, grpcAuthContext, providerPublicKeyPath) + val token = JWT.create() + .withClaim(SESSION_ID, sessionId) + .sign(Algorithm.RSA256(generatePrivateKey(providerPrivateKeyPath) as RSAPrivateKey)) + val token1 = JWT.create() + .withClaim(SESSION_ID, sessionId1) + .sign(Algorithm.RSA256(generatePrivateKey(providerPrivateKeyPath) as RSAPrivateKey)) + + `when`(authStub.authenticate(any(AuthRequest::class.java))) + .thenReturn( + Mono.just( + AuthOuterClass.AuthResponse.newBuilder() + .setProviderToken(token) + .build() + ) + ) + .thenReturn( + Mono.just( + AuthOuterClass.AuthResponse.newBuilder() + .setProviderToken(token1) + .build() + ) + ) + + grpcAuth.auth(providerId).block() + val result = grpcAuth.auth(providerId) + + StepVerifier.create(result) + .expectNext(GrpcUpstreamsAuth.AuthResult(true)) + .then { + assertEquals(sessionId1, grpcAuthContext.getToken(upstreamId)) + } + .expectComplete() + .verify(Duration.ofSeconds(3)) + } + + private fun generatePrivateKey(path: String): PrivateKey { + val privateKeyReader = StringReader(Files.readString(Paths.get(path))) + val privatePem = PEMParser(privateKeyReader).readPemObject() + val privateKeySpec = PKCS8EncodedKeySpec(privatePem.content) + + return KeyFactory.getInstance("RSA").generatePrivate(privateKeySpec) + } +} diff --git a/src/test/resources/configs/auth-with-client-settings.yaml b/src/test/resources/configs/auth-with-client-settings.yaml new file mode 100644 index 00000000..37077cc0 --- /dev/null +++ b/src/test/resources/configs/auth-with-client-settings.yaml @@ -0,0 +1,5 @@ +auth: + enabled: true + publicKeyOwner: drpc + client: + private-key: "classpath:keys/priv-wrong.p8.key" \ No newline at end of file diff --git a/src/test/resources/configs/auth-with-wrong-priv-key.yaml b/src/test/resources/configs/auth-with-wrong-priv-key.yaml index 96ed5bb6..7532e737 100644 --- a/src/test/resources/configs/auth-with-wrong-priv-key.yaml +++ b/src/test/resources/configs/auth-with-wrong-priv-key.yaml @@ -1,6 +1,7 @@ auth: enabled: true publicKeyOwner: drpc - keys: - provider-private-key: "classpath:keys/priv-wrong.p8.key" - external-public-key: "classpath:keys/pub-wrong.key" \ No newline at end of file + server: + keys: + provider-private-key: "classpath:keys/priv-wrong.p8.key" + external-public-key: "classpath:keys/pub-wrong.key" \ No newline at end of file diff --git a/src/test/resources/configs/auth-with-wrong-pub-key.yaml b/src/test/resources/configs/auth-with-wrong-pub-key.yaml index daf1260f..1e2bb040 100644 --- a/src/test/resources/configs/auth-with-wrong-pub-key.yaml +++ b/src/test/resources/configs/auth-with-wrong-pub-key.yaml @@ -1,6 +1,7 @@ auth: enabled: true publicKeyOwner: drpc - keys: - provider-private-key: "classpath:keys/priv.p8.key" - external-public-key: "classpath:keys/pub-wrong.key" \ No newline at end of file + server: + keys: + provider-private-key: "classpath:keys/priv.p8.key" + external-public-key: "classpath:keys/pub-wrong.key" \ No newline at end of file diff --git a/src/test/resources/configs/auth-without-any-config.yaml b/src/test/resources/configs/auth-without-any-config.yaml new file mode 100644 index 00000000..97bf98f3 --- /dev/null +++ b/src/test/resources/configs/auth-without-any-config.yaml @@ -0,0 +1,3 @@ +auth: + enabled: true + publicKeyOwner: drpc \ No newline at end of file diff --git a/src/test/resources/configs/auth-without-key-pair.yaml b/src/test/resources/configs/auth-without-key-pair.yaml index 97bf98f3..35f8d5b7 100644 --- a/src/test/resources/configs/auth-without-key-pair.yaml +++ b/src/test/resources/configs/auth-without-key-pair.yaml @@ -1,3 +1,5 @@ auth: enabled: true - publicKeyOwner: drpc \ No newline at end of file + publicKeyOwner: drpc + server: + nothing: true \ No newline at end of file diff --git a/src/test/resources/configs/auth-without-private-key.yaml b/src/test/resources/configs/auth-without-private-key.yaml index 1becf7a2..b876f042 100644 --- a/src/test/resources/configs/auth-without-private-key.yaml +++ b/src/test/resources/configs/auth-without-private-key.yaml @@ -1,5 +1,6 @@ auth: enabled: true publicKeyOwner: drpc - keys: - external-public-key: /keys/pub.key \ No newline at end of file + server: + keys: + external-public-key: /keys/pub.key \ No newline at end of file diff --git a/src/test/resources/configs/auth-without-public-key.yaml b/src/test/resources/configs/auth-without-public-key.yaml index 3f46575c..b00b35bf 100644 --- a/src/test/resources/configs/auth-without-public-key.yaml +++ b/src/test/resources/configs/auth-without-public-key.yaml @@ -1,5 +1,6 @@ auth: enabled: true publicKeyOwner: drpc - keys: - provider-private-key: /keys/priv.p8.key \ No newline at end of file + server: + keys: + provider-private-key: /keys/priv.p8.key \ No newline at end of file diff --git a/src/test/resources/configs/dshackle-full.yaml b/src/test/resources/configs/dshackle-full.yaml index c1afdc70..0d96f387 100644 --- a/src/test/resources/configs/dshackle-full.yaml +++ b/src/test/resources/configs/dshackle-full.yaml @@ -14,9 +14,10 @@ tls: auth: enabled: true publicKeyOwner: drpc - keys: - provider-private-key: "classpath:keys/priv.p8.key" - external-public-key: "classpath:keys/public.pem" + server: + keys: + provider-private-key: "classpath:keys/priv.p8.key" + external-public-key: "classpath:keys/public.pem" cache: redis: diff --git a/src/test/resources/configs/upstreams-extra.yaml b/src/test/resources/configs/upstreams-extra.yaml index 96458d6e..9722538c 100644 --- a/src/test/resources/configs/upstreams-extra.yaml +++ b/src/test/resources/configs/upstreams-extra.yaml @@ -6,4 +6,10 @@ upstreams: tls: ca: /etc/ca.myservice.com.crt certificate: /etc/client1.myservice.com.crt - key: /etc/client1.myservice.com.key \ No newline at end of file + key: /etc/client1.myservice.com.key + - id: remoteTokenAuth + connection: + grpc: + host: "11.12.10.115" + token-auth: + public-key: /path/to/key.pem \ No newline at end of file