Add authorization endpoint (#284)

This commit is contained in:
KirillPamPam
2023-08-23 15:57:08 +04:00
committed by GitHub
parent 31b301952a
commit 75e137161b
39 changed files with 949 additions and 9 deletions

View File

@@ -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
}
}

View File

@@ -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 {

View 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
)
}

View File

@@ -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)
}
}

View 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()
)
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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, "", "", "")
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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))
}