Add authorization endpoint (#284)
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package io.emeraldpay.dshackle
|
||||
|
||||
import io.emeraldpay.dshackle.config.AuthorizationConfig
|
||||
import io.emeraldpay.dshackle.config.CacheConfig
|
||||
import io.emeraldpay.dshackle.config.ChainsConfig
|
||||
import io.emeraldpay.dshackle.config.CompressionConfig
|
||||
@@ -153,4 +154,9 @@ open class Config(
|
||||
open fun chainsConfig(mainConfig: MainConfig): ChainsConfig {
|
||||
return mainConfig.chains
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun authorizationConfig(mainConfig: MainConfig): AuthorizationConfig {
|
||||
return mainConfig.authorization
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package io.emeraldpay.dshackle
|
||||
|
||||
import io.emeraldpay.dshackle.auth.AuthInterceptor
|
||||
import io.emeraldpay.dshackle.config.MainConfig
|
||||
import io.emeraldpay.dshackle.monitoring.accesslog.AccessHandlerGrpc
|
||||
import io.grpc.Codec
|
||||
@@ -29,8 +30,6 @@ import io.micrometer.core.instrument.Metrics
|
||||
import io.micrometer.core.instrument.Tag
|
||||
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.scheduling.concurrent.CustomizableThreadFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -46,9 +45,7 @@ open class GrpcServer(
|
||||
private val tlsSetup: TlsSetup,
|
||||
private val accessHandler: AccessHandlerGrpc,
|
||||
private val grpcServerBraveInterceptor: ServerInterceptor,
|
||||
@Autowired(required = false)
|
||||
@Qualifier("serverSpansInterceptor")
|
||||
private val serverSpansInterceptor: ServerInterceptor?
|
||||
private val authInterceptor: AuthInterceptor
|
||||
) {
|
||||
@Value("\${spring.application.max-metadata-size}")
|
||||
private var maxMetadataSize: Int = Defaults.maxMetadataSize
|
||||
@@ -87,9 +84,9 @@ open class GrpcServer(
|
||||
}
|
||||
|
||||
serverBuilder.intercept(grpcServerBraveInterceptor)
|
||||
serverSpansInterceptor?.let {
|
||||
serverBuilder.intercept(it)
|
||||
log.info("Collect spans from provider is enabled")
|
||||
if (mainConfig.authorization.enabled) {
|
||||
serverBuilder.intercept(authInterceptor)
|
||||
log.info("Token authorization is turned on")
|
||||
}
|
||||
|
||||
tlsSetup.setupServer("Native gRPC", mainConfig.tls, true)?.let {
|
||||
|
||||
25
src/main/kotlin/io/emeraldpay/dshackle/auth/AuthContext.kt
Normal file
25
src/main/kotlin/io/emeraldpay/dshackle/auth/AuthContext.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package io.emeraldpay.dshackle.auth
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class AuthContext {
|
||||
|
||||
companion object {
|
||||
val sessions = ConcurrentHashMap<String, TokenWrapper>()
|
||||
|
||||
fun putTokenInContext(tokenWrapper: TokenWrapper) {
|
||||
sessions[tokenWrapper.sessionId] = tokenWrapper
|
||||
}
|
||||
|
||||
fun removeToken(sessionId: String) {
|
||||
sessions.remove(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
data class TokenWrapper(
|
||||
val token: String,
|
||||
val issuedAt: Instant,
|
||||
val sessionId: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.emeraldpay.dshackle.auth
|
||||
|
||||
import io.emeraldpay.dshackle.auth.processor.SESSION_ID
|
||||
import io.grpc.Metadata
|
||||
import io.grpc.Metadata.ASCII_STRING_MARSHALLER
|
||||
import io.grpc.ServerCall
|
||||
import io.grpc.ServerCallHandler
|
||||
import io.grpc.ServerInterceptor
|
||||
import io.grpc.Status
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
const val AUTH_METHOD_NAME = "emerald.Auth/Authenticate"
|
||||
const val REFLECT_METHOD_NAME = "grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo"
|
||||
|
||||
@Component
|
||||
class AuthInterceptor : ServerInterceptor {
|
||||
private val specialMethods = setOf(AUTH_METHOD_NAME, REFLECT_METHOD_NAME)
|
||||
|
||||
override fun <ReqT : Any, RespT : Any> interceptCall(
|
||||
call: ServerCall<ReqT, RespT>,
|
||||
headers: Metadata,
|
||||
next: ServerCallHandler<ReqT, RespT>
|
||||
): ServerCall.Listener<ReqT> {
|
||||
val sessionId = headers.get(
|
||||
Metadata.Key.of(SESSION_ID, ASCII_STRING_MARSHALLER)
|
||||
)
|
||||
val isOrdinaryMethod = !specialMethods.contains(call.methodDescriptor.fullMethodName)
|
||||
|
||||
if (isOrdinaryMethod && (sessionId == null || !AuthContext.sessions.containsKey(sessionId))) {
|
||||
val cause = if (sessionId == null) "sessionId is not passed" else "Session $sessionId does not exist"
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription(cause)
|
||||
.asException()
|
||||
}
|
||||
|
||||
return next.startCall(call, headers)
|
||||
}
|
||||
}
|
||||
47
src/main/kotlin/io/emeraldpay/dshackle/auth/AuthRpc.kt
Normal file
47
src/main/kotlin/io/emeraldpay/dshackle/auth/AuthRpc.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package io.emeraldpay.dshackle.auth
|
||||
|
||||
import io.emeraldpay.api.proto.AuthOuterClass
|
||||
import io.emeraldpay.api.proto.ReactorAuthGrpc
|
||||
import io.emeraldpay.dshackle.auth.service.AuthService
|
||||
import io.grpc.Status
|
||||
import io.grpc.StatusException
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.core.scheduler.Scheduler
|
||||
|
||||
@Service
|
||||
class AuthRpc(
|
||||
private val authService: AuthService,
|
||||
private val authScheduler: Scheduler
|
||||
) : ReactorAuthGrpc.AuthImplBase() {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AuthRpc::class.java)
|
||||
}
|
||||
|
||||
override fun authenticate(request: Mono<AuthOuterClass.AuthRequest>): Mono<AuthOuterClass.AuthResponse> {
|
||||
log.info("Start auth process...")
|
||||
return request
|
||||
.subscribeOn(authScheduler)
|
||||
.map {
|
||||
val token = authService.authenticate(it.token)
|
||||
AuthOuterClass.AuthResponse.newBuilder()
|
||||
.setProviderToken(token)
|
||||
.build()
|
||||
}.onErrorResume {
|
||||
if (it is StatusException) {
|
||||
log.error(it.message)
|
||||
Mono.error(it)
|
||||
} else {
|
||||
val message = "Internal error: ${it.message}"
|
||||
log.error(message, it)
|
||||
Mono.error(
|
||||
Status.INTERNAL
|
||||
.withDescription(message)
|
||||
.asException()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package io.emeraldpay.dshackle.auth.processor
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.JWTVerifier
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import io.emeraldpay.dshackle.auth.AuthContext
|
||||
import io.emeraldpay.dshackle.auth.service.KeyReader
|
||||
import io.emeraldpay.dshackle.config.AuthorizationConfig
|
||||
import io.grpc.Status
|
||||
import org.springframework.stereotype.Component
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
const val SESSION_ID = "sessionId"
|
||||
const val VERSION = "version"
|
||||
|
||||
enum class AuthVersion {
|
||||
V1;
|
||||
|
||||
companion object {
|
||||
fun getVersion(version: String) = values().find { it.name == version }
|
||||
?: throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Unsupported auth version $version")
|
||||
.asException()
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AuthProcessor(
|
||||
private val authorizationConfig: AuthorizationConfig
|
||||
) {
|
||||
|
||||
open fun process(keys: KeyReader.Keys, token: String): AuthContext.TokenWrapper {
|
||||
try {
|
||||
val verifier: JWTVerifier = JWT.require(verifyingAlgorithm(keys.externalPublicKey))
|
||||
.withIssuer(authorizationConfig.publicKeyOwner)
|
||||
.build()
|
||||
verifier.verify(token)
|
||||
} catch (e: Exception) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Invalid token: ${e.message}")
|
||||
.asException()
|
||||
}
|
||||
|
||||
return processInternal(keys.providerPrivateKey)
|
||||
}
|
||||
|
||||
protected abstract fun processInternal(privateKey: PrivateKey): AuthContext.TokenWrapper
|
||||
|
||||
protected abstract fun verifyingAlgorithm(publicKey: PublicKey): Algorithm
|
||||
}
|
||||
|
||||
@Component
|
||||
open class AuthProcessorV1(
|
||||
authorizationConfig: AuthorizationConfig
|
||||
) : AuthProcessor(authorizationConfig) {
|
||||
|
||||
override fun processInternal(privateKey: PrivateKey): AuthContext.TokenWrapper {
|
||||
val issAt = Instant.now()
|
||||
val sessionId = UUID.randomUUID().toString()
|
||||
val token = JWT.create()
|
||||
.withIssuedAt(issAt)
|
||||
.withClaim(SESSION_ID, sessionId)
|
||||
.withClaim(VERSION, AuthVersion.V1.toString())
|
||||
.sign(Algorithm.RSA256(privateKey as RSAPrivateKey))
|
||||
|
||||
return AuthContext.TokenWrapper(token, issAt, sessionId)
|
||||
}
|
||||
|
||||
override fun verifyingAlgorithm(publicKey: PublicKey): Algorithm =
|
||||
Algorithm.RSA256(publicKey as RSAPublicKey, null)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package io.emeraldpay.dshackle.auth.processor
|
||||
|
||||
import com.auth0.jwt.interfaces.DecodedJWT
|
||||
import io.grpc.Status
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class AuthProcessorResolver(
|
||||
private val authProcessorV1: AuthProcessor
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AuthProcessorResolver::class.java)
|
||||
}
|
||||
|
||||
fun getAuthProcessor(token: DecodedJWT): AuthProcessor {
|
||||
val claimVersion = token.getClaim(VERSION)
|
||||
if (claimVersion.isMissing) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Version is not specified in the token")
|
||||
.asException()
|
||||
}
|
||||
|
||||
val version = AuthVersion.getVersion(claimVersion.asString())
|
||||
log.info("Using $version of authentication")
|
||||
|
||||
if (version == AuthVersion.V1) {
|
||||
return authProcessorV1
|
||||
}
|
||||
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Unsupported auth version $version")
|
||||
.asException()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.emeraldpay.dshackle.auth.processor
|
||||
|
||||
import io.emeraldpay.dshackle.auth.AuthContext
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Component
|
||||
open class TokenProcessor {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TokenProcessor::class.java)
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30000)
|
||||
fun invalidateTokens() {
|
||||
AuthContext.sessions
|
||||
.filter { Instant.now().isAfter(it.value.issuedAt.plus(1, ChronoUnit.HOURS)) }
|
||||
.forEach {
|
||||
log.info("Invalidate token with sessionId ${it.key}")
|
||||
AuthContext.removeToken(it.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.emeraldpay.dshackle.auth.service
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import io.emeraldpay.dshackle.auth.AuthContext
|
||||
import io.emeraldpay.dshackle.auth.processor.AuthProcessorResolver
|
||||
import io.emeraldpay.dshackle.config.AuthorizationConfig
|
||||
import io.grpc.Status
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class AuthService(
|
||||
private val authorizationConfig: AuthorizationConfig,
|
||||
private val rsaKeyReader: KeyReader,
|
||||
private val authProcessorResolver: AuthProcessorResolver
|
||||
) {
|
||||
|
||||
fun authenticate(token: String): String {
|
||||
if (!authorizationConfig.enabled) {
|
||||
throw Status.UNIMPLEMENTED
|
||||
.withDescription("Authentication process is not enabled")
|
||||
.asException()
|
||||
}
|
||||
|
||||
val keys = rsaKeyReader.getKeyPair(
|
||||
authorizationConfig.providerPrivateKeyPath, authorizationConfig.externalPublicKeyPath
|
||||
)
|
||||
val decodedJwt = JWT.decode(token)
|
||||
|
||||
return authProcessorResolver
|
||||
.getAuthProcessor(decodedJwt)
|
||||
.process(keys, token)
|
||||
.run {
|
||||
AuthContext.putTokenInContext(this)
|
||||
this.token
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.emeraldpay.dshackle.auth.service
|
||||
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
|
||||
interface KeyReader {
|
||||
|
||||
fun getKeyPair(providerPrivateKeyPath: String, externalPublicKeyPath: String): Keys
|
||||
|
||||
data class Keys(
|
||||
val providerPrivateKey: PrivateKey,
|
||||
val externalPublicKey: PublicKey
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.emeraldpay.dshackle.auth.service
|
||||
|
||||
import org.bouncycastle.openssl.PEMParser
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.StringReader
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
@Component
|
||||
class RsaKeyReader : KeyReader {
|
||||
private val factory = KeyFactory.getInstance("RSA")
|
||||
|
||||
override fun getKeyPair(providerPrivateKeyPath: String, externalPublicKeyPath: String): KeyReader.Keys {
|
||||
val privateKeyReader = StringReader(Files.readString(Paths.get(providerPrivateKeyPath)))
|
||||
val publicKeyReader = StringReader(Files.readString(Paths.get(externalPublicKeyPath)))
|
||||
|
||||
val privatePem = PEMParser(privateKeyReader).readPemObject()
|
||||
val publicPem = PEMParser(publicKeyReader).readPemObject()
|
||||
|
||||
val privateKeySpec = PKCS8EncodedKeySpec(privatePem.content)
|
||||
val publicKeySpec = X509EncodedKeySpec(publicPem.content)
|
||||
|
||||
val pubKey = factory.generatePublic(publicKeySpec)
|
||||
val privateKey = factory.generatePrivate(privateKeySpec)
|
||||
|
||||
return KeyReader.Keys(
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.emeraldpay.dshackle.config
|
||||
|
||||
data class AuthorizationConfig(
|
||||
val enabled: Boolean,
|
||||
val publicKeyOwner: String,
|
||||
val providerPrivateKeyPath: String,
|
||||
val externalPublicKeyPath: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun default() = AuthorizationConfig(false, "", "", "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.emeraldpay.dshackle.config
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.util.ResourceUtils
|
||||
import org.yaml.snakeyaml.nodes.MappingNode
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class AuthorizationConfigReader : YamlConfigReader<AuthorizationConfig>() {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(AuthorizationConfigReader::class.java)
|
||||
}
|
||||
|
||||
override fun read(input: MappingNode?): AuthorizationConfig {
|
||||
val auth = getMapping(input, "auth")
|
||||
if (auth == null) {
|
||||
log.warn("Authorization is not using")
|
||||
return AuthorizationConfig.default()
|
||||
}
|
||||
|
||||
val enabled = getValueAsBool(auth, "enabled")
|
||||
if (enabled == null || !enabled) {
|
||||
log.warn("Authorization is not enabled")
|
||||
return AuthorizationConfig.default()
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
if (fileNotExists(privateKey)) {
|
||||
throw IllegalStateException("There is no such file: $privateKey")
|
||||
}
|
||||
if (fileNotExists(publicKey)) {
|
||||
throw IllegalStateException("There is no such file: $publicKey")
|
||||
}
|
||||
|
||||
return AuthorizationConfig(enabled, publicKeyOwner, privateKey, publicKey)
|
||||
}
|
||||
|
||||
private fun fileNotExists(path: String): Boolean {
|
||||
return try {
|
||||
!ResourceUtils.getFile(path).exists()
|
||||
} catch (e: FileNotFoundException) {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,5 @@ class MainConfig {
|
||||
var signature: SignatureConfig? = null
|
||||
var compression: CompressionConfig = CompressionConfig.default()
|
||||
var chains: ChainsConfig = ChainsConfig.default()
|
||||
var authorization: AuthorizationConfig = AuthorizationConfig.default()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class MainConfigReader(
|
||||
private val signatureConfigReader = SignatureConfigReader(fileResolver)
|
||||
private val compressionConfigReader = CompressionConfigReader()
|
||||
private val chainsConfigReader = ChainsConfigReader(upstreamsConfigReader)
|
||||
private val authorizationConfigReader = AuthorizationConfigReader()
|
||||
|
||||
override fun read(input: MappingNode?): MainConfig {
|
||||
val config = MainConfig()
|
||||
@@ -80,6 +81,9 @@ class MainConfigReader(
|
||||
chainsConfigReader.read(input).let {
|
||||
config.chains = it
|
||||
}
|
||||
authorizationConfigReader.read(input).let {
|
||||
config.authorization = it
|
||||
}
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ open class SchedulersConfig {
|
||||
return makePool("grpc-client-channel", 10, monitoringConfig)
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun authScheduler(monitoringConfig: MonitoringConfig): Scheduler {
|
||||
return makeScheduler("auth-scheduler", 4, monitoringConfig)
|
||||
}
|
||||
|
||||
private fun makeScheduler(name: String, size: Int, monitoringConfig: MonitoringConfig): Scheduler {
|
||||
return Schedulers.fromExecutorService(makePool(name, size, monitoringConfig))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user